MCP server for Stalwart CardDAV (axum + rmcp). Bring your own IdP. Self-host.
  • Rust 99.7%
  • Dockerfile 0.3%
Find a file
Julian Lindner ade7ad309c
All checks were successful
CI / cargo (push) Successful in 38s
CI / docker (push) Successful in 57s
Merge pull request 'docs(agents): the image shape, and the review gate' (#31) from docs/image-shape-and-gate into main
2026-09-01 15:33:47 +00:00
.forgejo/workflows fix(ci): serialize registry cache ownership 2026-08-19 15:38:15 +08:00
docs chore(release): prepare v0.1.3 2026-08-25 12:50:21 +08:00
src docs: use documentation-range addresses in the deployment notes 2026-09-01 23:31:22 +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
.trivyignore fix(container): refresh distroless runtime 2026-08-19 15:12:47 +08:00
AGENTS.md Merge pull request 'docs(agents): the image shape, and the review gate' (#31) from docs/image-shape-and-gate into main 2026-09-01 15:33:47 +00:00
Cargo.lock feat(mcp): upcoming_birthdays, and parse BDAY at all 2026-08-29 08:15:33 +08:00
Cargo.toml feat(mcp): upcoming_birthdays, and parse BDAY at all 2026-08-29 08:15:33 +08:00
CHANGELOG.md feat(mcp): upcoming_birthdays, and parse BDAY at all 2026-08-29 08:15:33 +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(container): refresh distroless runtime 2026-08-19 15:12:47 +08:00
LICENSE feat(carddav): implement Stalwart MCP server 2026-08-17 07:16:33 +08:00
README.md feat(mcp): upcoming_birthdays, and parse BDAY at all 2026-08-29 08:15:33 +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
  • list_contacts
  • 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.

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

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

License

MIT.