MCP server for Stalwart CardDAV (axum + rmcp). Bring your own IdP. Self-host.
  • Rust 98.9%
  • Python 0.7%
  • Dockerfile 0.2%
  • Shell 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-31 07:23:07 +00:00
.forgejo/workflows fix: publish the exact scanned attested OCI artifact 2026-10-29 05:09:57 +08:00
docs fix(sync): review round 1: sibling tombstones are not a wrong book; probe the href per pass; refuse truncated listings and a server behind the token 2026-10-24 12:56:00 +08:00
scripts fix: publish the exact scanned attested OCI artifact 2026-10-29 05:09:57 +08:00
src test(birthdays): review round 2: pin the per-page dedup; correct the Contacts card count 2026-10-31 15:17:41 +08:00
tests/fixtures test: verify signed JWKS authentication across crypto upgrade 2026-10-29 04:56:19 +08:00
.env.example feat(release): prepare v0.1.1 public release 2026-08-19 15:03:57 +08:00
.gitignore feat(carddav): implement Stalwart MCP server 2026-08-17 07:16:33 +08:00
AGENTS.md fix(birthdays): review round 1: pin the multi-book count, the result mapping, unparseable cards and the page filter 2026-10-31 15:14:35 +08:00
Cargo.lock fix(birthdays): read only birthday fields in bounded partial multiget pages (v0.4.2) 2026-10-31 15:03:52 +08:00
Cargo.toml fix(birthdays): read only birthday fields in bounded partial multiget pages (v0.4.2) 2026-10-31 15:03:52 +08:00
CHANGELOG.md fix(birthdays): read only birthday fields in bounded partial multiget pages (v0.4.2) 2026-10-31 15:03:52 +08:00
CLAUDE.md docs: correct the redirect-matching and IdP-support claims 2026-08-26 12:57:19 +08:00
CODE_OF_CONDUCT.md feat(release): prepare v0.1.1 public release 2026-08-19 15:03:57 +08:00
compose.yaml chore(release): prepare v0.1.3 2026-08-25 12:50:21 +08:00
CONTRIBUTING.md feat(release): prepare v0.1.1 public release 2026-08-19 15:03:57 +08:00
deny.toml feat(carddav): implement Stalwart MCP server 2026-08-17 07:16:33 +08:00
Dockerfile fix: qualify fixed non-root runtime for v0.4.1 2026-10-29 05:00:44 +08:00
LICENSE feat(carddav): implement Stalwart MCP server 2026-08-17 07:16:33 +08:00
README.md feat(sharing,sync): list shared address books with owner and rights; sync_contacts over sync-collection (v0.4.0) 2026-10-24 12:26:10 +08:00
rustfmt.toml feat(carddav): implement Stalwart MCP server 2026-08-17 07:16:33 +08:00
SECURITY.md feat(release): prepare v0.1.1 public release 2026-08-19 15:03:57 +08:00

carddav-mcp

carddav-mcp is a self-hosted, streamable-HTTP MCP server for per-user address books on Stalwart CardDAV. It validates a user's JWT with a Logto-compatible identity provider and forwards that same bearer to Stalwart. It never accepts or stores Basic credentials or app passwords.

The public MCP endpoint is https://your-host.example/mcp.

Tools

  • whoami
  • list_address_books (the caller's own and the ones shared with them, with rights)
  • list_contacts (one page at a time, cursor for the next)
  • get_contact
  • sync_contacts (what changed since a sync_token: additions, updates, deletions)
  • search_contacts
  • upcoming_birthdays
  • create_contact
  • update_contact
  • delete_contact

Read and write tools carry MCP annotations so compatible clients can distinguish read-only, destructive, and idempotent operations.

Contact model

Contacts are vCards, read and written losslessly. Every returned contact has typed fields (full_name, given_name, emails, phones, addresses, birthday, anniversary, organization, related, photo, ...), a properties list holding the whole card with parameters, and the raw vcard.

create_contact writes vCard 3.0 by default, which is what Apple and most CardDAV clients expect, or RFC 6350 vCard 4.0 with version: "4.0". update_contact changes only the fields it is given: omitted fields keep their stored value, null removes one, and every property the call does not name (PHOTO, ADR, X-ABRELATEDNAMES, unknown extensions) is preserved as stored. Birthdays and anniversaries take YYYY-MM-DD or --MM-DD for an unknown year. Any other property can be written by name through set_properties and remove_properties. Converting a card between versions is explicit and refuses, rather than drops, properties the target cannot hold.

list_contacts returns up to 100 cards per call in href order with a next_cursor for the following page, complete when none follows and missing for members listed but not returned; the cursor is bound to the address book and reports when the book changed under it. get_contact fetches one card by href with its ETag, in the server's default vCard rendering or the version asked for.

list_address_books also returns address books other accounts shared with the caller, each with its owner, shared: true and rights (owner, read-write, read, custom, none) read from the server's current-user-privilege-set. Contact tools take those hrefs like any other; Stalwart's ACL decides what each permits, and a write the share does not allow is refused with a 403 that names the rights field, with nothing changed. This server has no tool that grants or revokes shares, on purpose: it acts as whoever presented the bearer, and an agent holding the owner's session must not be able to widen access to the owner's contacts. Owners share from their own DAV client (see docs/sharing-and-sync.md).

sync_contacts reports what changed in one address book since a sync_token: cards added or changed, with bodies and ETags, and the hrefs of cards deleted, up to limit entries per call with complete when the pass has reported everything. Without a token it reports every card (the initial sync). The token is opaque and bound to the book; one this server did not issue, or for another book, is refused with instructions to start over. A client that applies each page by href never loses a change; a card that changes while a multi-page sync runs may be reported again next time. The server side is RFC 6578 sync-collection, paged here rather than with the server's limit, for the reasons in the same document.

Stalwart stores a card's properties, not its bytes, and renders the vCard version each reader asks for, 3.0 for readers that do not ask. A write is therefore read back in the version it was written in before it is reported, and undone if the server does not serve it so; a 4.0 result says in warnings how 3.0 readers (Apple Contacts) receive the card. An update without version writes the version those readers see unless the data cannot live there. list_contacts and search_contacts take an optional version for the rendering returned. docs/vcard-inventory.md lists every property and parameter of both RFCs with its handling, and how versions behave on the server.

Security model

The MCP server, identity provider, Stalwart, and MCP client are all inside the trust boundary. The server validates JWT signature, issuer, audience, and expiry before opening an MCP session. It forwards the bearer to Stalwart only over HTTPS and never logs or persists the token. CardDAV URLs are confined to the configured DAV origin, response and request bodies are bounded, and identity-keyed in-memory state has hard cardinality limits.

Anyone operating an instance can access its process and traffic. Self-host it with an identity provider and Stalwart installation that you control. See SECURITY.md for supported versions and private reporting.

Prerequisites

  • A public HTTPS hostname for this service.
  • Stalwart CardDAV configured to accept JWT bearers from the same issuer.
  • A Logto API resource whose indicator equals the MCP server origin.
  • A public PKCE client in Logto containing <origin>/oauth/callback.
  • An MCP client supporting streamable HTTP and OAuth 2.1.

This release is Logto-compatible, not arbitrary-OIDC compatible. The authorization-server base must expose /auth, /token, /jwks, and /me in Logto's layout. Supporting unrelated providers requires standards-based OIDC discovery that is not implemented yet.

Quick start with Docker Compose

The published container is Linux AMD64.

git clone https://forge.oddie.app/jlxq0/carddav-mcp.git
cd carddav-mcp
cp .env.example .env
# Edit .env for your IdP, Stalwart, public hostname, and client redirects.
docker compose up -d
curl http://127.0.0.1:3000/health

Terminate TLS in a reverse proxy and preserve streaming responses. For Caddy, use flush_interval -1 on the reverse_proxy transport.

Connect the MCP client to https://your-host.example/mcp. The server publishes RFC 9728 protected-resource metadata at /.well-known/oauth-protected-resource/mcp and an RFC 8414 authorization-server document at /.well-known/oauth-authorization-server.

Configuration

Variable Required Default Purpose
CARDDAV_MCP_RESOURCE_URL yes — Public origin without /mcp; also the JWT audience.
CARDDAV_MCP_AUTHORIZATION_SERVER yes — Logto OIDC issuer base.
CARDDAV_MCP_STALWART_DAV_BASE_URL yes — Public HTTPS origin serving Stalwart DAV.
CARDDAV_MCP_DCR_CLIENT_ID recommended — Pre-provisioned public PKCE client returned by the DCR shim.
CARDDAV_MCP_OAUTH_REDIRECT_URIS yes when DCR is enabled — Exact comma-separated MCP-client redirect allowlist.
CARDDAV_MCP_STALWART_AUDIENCE no resource URL Resource indicator Stalwart accepts. Keep it service-specific where possible.
CARDDAV_MCP_BIND_ADDR no 0.0.0.0:3000 Public HTTP listener.
CARDDAV_MCP_METRICS_BIND_ADDR no 127.0.0.1:9090 or POD_IP:9090 Internal Prometheus listener.
CARDDAV_MCP_RATE_LIMIT_READS_PER_MIN no 60 Per-token and per-subject read quota.
CARDDAV_MCP_RATE_LIMIT_WRITES_PER_MIN no 30 Per-token and per-subject write quota.
CARDDAV_MCP_DAV_MAX_RESPONSE_BYTES no 8388608 Maximum accepted DAV response body.
CARDDAV_MCP_TRUSTED_PROXY_HOPS no 2 Trusted rightmost X-Forwarded-For hops. Must equal the measured length of your proxy chain.
CARDDAV_MCP_STALWART_CONNECT_IP no DNS Optional DAV DNS override while retaining Host/SNI.
CARDDAV_MCP_LOG_FORMAT no text Set to json for structured logs.
RUST_LOG no application default Tracing filter.
OTEL_EXPORTER_OTLP_ENDPOINT no disabled Enables OTLP tracing export.

Public resource, IdP, and DAV URLs must use HTTPS. Plain HTTP is accepted only for loopback development URLs, and HTTP callbacks are limited to loopback hosts.

Redirect URIs match exactly, with one exception: a cleartext loopback entry matches on any port, as RFC 8252 §7.3 requires, because a native client binds an ephemeral local port per session. Scheme, host, path and query still match exactly on both sides, so allowlist one loopback entry rather than one per port. https and private-use entries stay byte-for-byte exact, port included.

Logto and Stalwart setup

  1. Create a Logto API resource with an indicator exactly equal to CARDDAV_MCP_RESOURCE_URL.
  2. Create or reuse a public SPA/PKCE client and add <CARDDAV_MCP_RESOURCE_URL>/oauth/callback to its redirects.
  3. Put that client ID in CARDDAV_MCP_DCR_CLIENT_ID.
  4. Configure Stalwart's OIDC directory to trust the same issuer and accept the resource audience.
  5. Put only the MCP clients' exact callback URIs in CARDDAV_MCP_OAUTH_REDIRECT_URIS; never add wildcards.

The service returns its static Logto client through a constrained DCR shim. It proxies authorization and token requests on its own origin because remote MCP clients require same-origin OAuth metadata. It does not mint tokens.

Deployment

  • compose.yaml is the smallest local/self-hosted example.
  • docs/kubernetes.yaml is a restricted, non-root Kubernetes starting point. Add your ingress, TLS, and namespace-specific NetworkPolicy.
  • /health is unauthenticated and returns JSON containing status and version.
  • /metrics is served on a separate listener; do not expose it publicly.

Pin production images by both tag and digest. The official deployment uses a read-only root filesystem, drops every Linux capability, and allows egress only to DNS and HTTPS dependencies.

Forgejo Actions runs formatting, Clippy, tests, dependency and secret checks, then builds and scans a Linux AMD64 OCI image. A v* tag publishes the versioned image and latest to forge.oddie.app/jlxq0/carddav-mcp, with CycloneDX, SPDX, and SLSA metadata. The production cluster is updated separately through the oddie-apps/platform GitOps repository; Renovate proposes the digest change and Argo CD applies it after that platform change is merged. This repository does not deploy directly to Kubernetes.

Development

Rust 1.93+ with edition 2024:

cargo fmt --all --check
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test --all-features --locked
cargo audit
cargo deny check advisories bans licenses sources
gitleaks detect --source . --no-banner

cargo audit and cargo deny check advisories also run against main every week (.forgejo/workflows/advisories.yml) and file an issue titled "Scheduled advisory check failed on main" when they fail, so an advisory published after a release does not wait for the next pull request.

See CONTRIBUTING.md before submitting a change and CHANGELOG.md for release notes.

License

MIT.