- Rust 98.9%
- Python 0.7%
- Dockerfile 0.2%
- Shell 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| docs | ||
| scripts | ||
| src | ||
| tests/fixtures | ||
| .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 | ||
| rustfmt.toml | ||
| SECURITY.md | ||
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
whoamilist_address_books(the caller's own and the ones shared with them, withrights)list_contacts(one page at a time,cursorfor the next)get_contactsync_contacts(what changed since async_token: additions, updates, deletions)search_contactsupcoming_birthdayscreate_contactupdate_contactdelete_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
- Create a Logto API resource with an indicator exactly equal to
CARDDAV_MCP_RESOURCE_URL. - Create or reuse a public SPA/PKCE client and add
<CARDDAV_MCP_RESOURCE_URL>/oauth/callbackto its redirects. - Put that client ID in
CARDDAV_MCP_DCR_CLIENT_ID. - Configure Stalwart's OIDC directory to trust the same issuer and accept the resource audience.
- 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.yamlis the smallest local/self-hosted example.docs/kubernetes.yamlis a restricted, non-root Kubernetes starting point. Add your ingress, TLS, and namespace-specific NetworkPolicy./healthis unauthenticated and returns JSON containing status and version./metricsis 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.