No description
  • Rust 99.7%
  • Dockerfile 0.3%
Find a file
Clark 07e591ff20
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
chore(release): prepare 0.2.22 pagination release
2026-09-15 05:52:09 +00:00
.forgejo/workflows fix(ci): refuse a release tag that is not an ancestor of main 2026-08-27 10:17:17 +08:00
.github/workflows fix(ci): refuse a release tag that is not an ancestor of main 2026-08-27 10:17:17 +08:00
src feat(mcp): add checkpointed read-only email ID pagination 2026-09-15 05:18:51 +00:00
.dockerignore feat(release): prepare secure public v0.2.10 2026-08-19 15:12:41 +08:00
.env.example docs(env): use a placeholder in the example config 2026-08-23 00:02:18 +08:00
.gitignore chore: stop tracking Playwright MCP session snapshots 2026-07-07 12:17:25 +08:00
AGENTS.md fix(deps): patch rustls TLS handshake advisory 2026-09-15 05:38:30 +00:00
Cargo.lock chore(release): prepare 0.2.22 pagination release 2026-09-15 05:52:09 +00:00
Cargo.toml chore(release): prepare 0.2.22 pagination release 2026-09-15 05:52:09 +00:00
CHANGELOG.md fix(compose): carry the identity display name into From 2026-08-20 06:46:11 +08:00
CLAUDE.md docs: fold CLAUDE.md into AGENTS.md and date the mirror pitfall 2026-08-26 12:44:40 +08:00
CODE_OF_CONDUCT.md feat(release): prepare secure public v0.2.10 2026-08-19 15:12:41 +08:00
compose.yaml fix(compose): carry the identity display name into From 2026-08-20 06:46:11 +08:00
CONTRIBUTING.md feat(release): prepare secure public v0.2.10 2026-08-19 15:12:41 +08:00
deny.toml feat: jmap-mcp — remote MCP server bridging claude.ai to a Stalwart JMAP mailbox 2026-06-02 09:53:12 +08:00
Dockerfile fix(identities): expose mailbox aliases as sendable From-addresses 2026-08-19 22:30:53 +08:00
LICENSE feat: jmap-mcp — remote MCP server bridging claude.ai to a Stalwart JMAP mailbox 2026-06-02 09:53:12 +08:00
README.md feat(mcp): add checkpointed read-only email ID pagination 2026-09-15 05:18:51 +00:00
rust-toolchain.toml feat(release): prepare secure public v0.2.10 2026-08-19 15:12:41 +08:00
rustfmt.toml feat: jmap-mcp — remote MCP server bridging claude.ai to a Stalwart JMAP mailbox 2026-06-02 09:53:12 +08:00
SECURITY.md fix(compose): carry the identity display name into From 2026-08-20 06:46:11 +08:00

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, 1100 (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 19 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 total says results exist at the position that was requested;
  • an empty page under a server-reported enforced limit of 0, 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/amd64 only. No arm64 variant is published, so on Apple Silicon, AWS Graviton, or any other ARM host docker pull fails with no matching manifest for linux/arm64/v8 in the manifest list entries. Add --platform linux/amd64 to 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:

  1. Create a Logto API resource whose indicator is the bare jmap-mcp origin, such as https://jmap-mcp.your-domain.example.

  2. 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.

  3. Register https://jmap-mcp.your-domain.example/oauth/callback as the Logto application's redirect URI.

  4. 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.

  5. 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, or sub claim to the mailbox principal.

  6. 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.