mcp-icloud
mcp-icloud is a self-hosted Model Context Protocol (MCP) server for iCloud. It lets MCP clients such as claude.ai, Claude Desktop and Claude Code work with your iCloud mail, calendars and contacts, using an app-specific password that never leaves your server.
One server runs every service. Each service is its own MCP endpoint, and a combined endpoint offers all of them in one connector:
| Endpoint | Service | What it does |
|---|---|---|
/mail | Search, read, organise and draft email; optionally send | |
/calendar | Calendar | List, search, create, edit and delete events, including recurring ones; create, rename and delete calendars; find free time; optionally manage invitations |
/contacts | Contacts | Search, read, create, edit and delete contacts; groups (iOS Lists); upcoming birthdays and anniversaries; find and merge duplicates |
/mcp | Combined | Every enabled service’s tools in one connector, named <service>_<tool> |
Highlights
- A single static Rust binary, also shipped as a
FROM scratchcontainer image (ghcr.io/trozz/mcp-icloud). - TLS end to end: the server terminates TLS itself, and every connection to Apple is verified.
- A built-in OAuth 2.1 authorization server, so each endpoint works as a claude.ai custom connector. No database and no volume are needed.
- Safe by default: sending mail and changing invitations are off until you enable them, and any service can be made read-only.
- Every write is recorded in an audit log that never contains your content.
How it talks to Apple
iCloud has no public API for these services. The server uses the standard protocols Apple supports for third-party apps:
| Purpose | Protocol | Host |
|---|---|---|
| Reading and organising mail | IMAP | imap.mail.me.com:993 |
| Sending mail | SMTP with STARTTLS | smtp.mail.me.com:587 |
| Calendars | CalDAV | caldav.icloud.com, then your account’s partition host such as p99-caldav.icloud.com |
| Contacts | CardDAV | contacts.icloud.com, then your account’s partition host such as p99-contacts.icloud.com |
What it cannot do
Some iCloud data has no supported protocol, so the server does not offer it:
- Reminders: Apple no longer exposes upgraded iCloud Reminders over CalDAV.
- Notes, iCloud Drive, Photos and Hide My Email: only reachable through Apple’s private web APIs with two-factor sign-in, which this project does not use.
Privacy
Your Apple password stays on your server and is only sent to Apple. Logs record tool names, outcomes, durations and ids, never message, event or contact content, unless you explicitly turn on unsafe content logging while debugging. See Security.
Next: Getting started.
Getting started
This page takes you from nothing to a running server. You need an Apple account with two-factor authentication, and a machine that can run Docker or a single binary.
1. Create an app-specific password
- Sign in at account.apple.com.
- Open Sign-In and Security, then App-Specific Passwords.
- Create one named
mcp-icloudand copy it. It looks likexxxx-xxxx-xxxx-xxxx.
The same password is used for mail, calendar and contacts. You can revoke it at any time on the same page, which immediately cuts the server off from your account.
2. Choose secrets for the server
The server needs two secrets of its own:
OAUTH_SIGNING_KEY: at least 32 bytes, used to sign OAuth tokens. Generate one withopenssl rand -hex 32and keep it stable: changing it signs every client out.OAUTH_ADMIN_PASSWORD: at least 12 characters (15 or more avoids a startup warning). You type it on the server’s login page when you add a connector in claude.ai.
Optionally also MCP_AUTH_TOKEN (for example openssl rand -hex 32), a static bearer token for clients that can send a header, such as Claude Code.
Store these in a secret manager or an environment file readable only by you. Every secret can also be read from a file: append _FILE to the variable name, for example ICLOUD_APP_PASSWORD_FILE=/run/secrets/icloud.
3. Run it
With Docker
docker run -d --name mcp-icloud -p 8443:8443 \
-e ICLOUD_EMAIL=you@icloud.com \
-e ICLOUD_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
-e PUBLIC_URL=https://icloud.example.com \
-e OAUTH_SIGNING_KEY="$(openssl rand -hex 32)" \
-e OAUTH_ADMIN_PASSWORD=choose-a-password-of-12-or-more-chars \
-e CALENDAR_TIMEZONE=Europe/London \
-e CONTACTS_TIMEZONE=Europe/London \
ghcr.io/trozz/mcp-icloud:latest
PUBLIC_URL is the address clients use to reach the server. See TLS and public access for how to make it reachable with a trusted certificate.
With Docker Compose and a Cloudflare tunnel
A tunnel gives the server a public HTTPS address with a trusted certificate, without opening a port on your network. Create a tunnel in the Cloudflare dashboard, add a public hostname such as icloud.example.com pointing to https://mcp-icloud:8443 with No TLS Verify turned on (the server’s own certificate is self-signed in this setup), and copy the tunnel token.
compose.yaml:
services:
mcp-icloud:
image: ghcr.io/trozz/mcp-icloud:latest
container_name: mcp-icloud
restart: unless-stopped
env_file: app.env
ports:
- "127.0.0.1:8443:8443"
volumes:
- ./audit:/audit
tunnel:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN}
depends_on:
- mcp-icloud
app.env (readable only by you, for example chmod 600 app.env):
ICLOUD_EMAIL=you@icloud.com
ICLOUD_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
PUBLIC_URL=https://icloud.example.com
OAUTH_SIGNING_KEY=replace-with-the-output-of-openssl-rand-hex-32
OAUTH_ADMIN_PASSWORD=choose-a-password-of-12-or-more-chars
MCP_AUTH_TOKEN=replace-with-another-random-value
CALENDAR_TIMEZONE=Europe/London
CONTACTS_TIMEZONE=Europe/London
AUDIT_LOG_PATH=/audit/audit.jsonl
The server runs as UID 65532, so the audit directory must be writable by it: mkdir -p audit && sudo chown 65532:65532 audit. Then start both containers with TUNNEL_TOKEN=<token> docker compose up -d.
With the binary
Download the archive for your platform from Releases (Linux x86_64 and arm64, macOS arm64), verify the checksum, extract it and run:
shasum -a 256 -c mcp-icloud-<version>-<target>.tar.gz.sha256
tar -xzf mcp-icloud-<version>-<target>.tar.gz
cd mcp-icloud-<version>-<target>
ICLOUD_EMAIL=you@icloud.com ICLOUD_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
PUBLIC_URL=https://icloud.example.com OAUTH_SIGNING_KEY=... OAUTH_ADMIN_PASSWORD=... \
./mcp-icloud
All configuration comes from environment variables; the configuration reference lists every one.
4. Check it is running
curl -k https://127.0.0.1:8443/healthz
prints ok. The startup log has one service enabled line per service with its URL and tools, and the contacts service logs contacts loaded with the number of cards once it has read your address book.
mcp-icloud --version prints the version, and mcp-icloud --healthcheck exits 0 when the local server answers /healthz (the container image uses it as its HEALTHCHECK).
5. Choose what runs
ICLOUD_SERVICES=mail,calendarruns only some services (default: all). A service that is not enabled is not mounted and does not read its configuration.ICLOUD_READ_ONLY=contacts(orall) hides and refuses the write tools of those services.ICLOUD_ALLOW_SEND=trueenables sending mail, andICLOUD_ALLOW_INVITES=trueenables changing attendees and replying to invitations. Both are off by default.
Next: Connecting clients.
Connecting clients
Each service is a separate MCP server at its own path, and /mcp offers all of them together. Add one connector per endpoint you want, or a single connector for /mcp.
| Endpoint | Connector URL |
|---|---|
https://icloud.example.com/mail | |
| Calendar | https://icloud.example.com/calendar |
| Contacts | https://icloud.example.com/contacts |
| All services | https://icloud.example.com/mcp |
Use either the per-service connectors or the combined one, not both: with both, every tool appears twice.
claude.ai, Claude Desktop and Claude mobile
These connect from Anthropic’s servers, so the server must be reachable from the internet with a publicly trusted certificate (see TLS and public access).
- In claude.ai open Settings, then Connectors, then Add custom connector.
- Enter the connector URL, for example
https://icloud.example.com/contacts. - A login page from your server opens and names the service. Enter
OAUTH_ADMIN_PASSWORD.
The connector then signs in with OAuth on its own. Access tokens last one hour and refresh tokens 30 days; the client refreshes its access token itself. Every sign-in ends 90 days after it was made, or when OAUTH_ADMIN_PASSWORD changes; the connector then asks you to connect again. Connectors added in claude.ai are also available in Claude Desktop and the Claude mobile apps.
Claude Code
Claude Code can send a header, so it can use the static token. Set MCP_AUTH_TOKEN on the server, then:
claude mcp add --transport http icloud https://icloud.example.com/mcp \
--header "Authorization: Bearer $MCP_AUTH_TOKEN"
or one server per service:
claude mcp add --transport http icloud-mail https://icloud.example.com/mail \
--header "Authorization: Bearer $MCP_AUTH_TOKEN"
claude mcp add --transport http icloud-calendar https://icloud.example.com/calendar \
--header "Authorization: Bearer $MCP_AUTH_TOKEN"
claude mcp add --transport http icloud-contacts https://icloud.example.com/contacts \
--header "Authorization: Bearer $MCP_AUTH_TOKEN"
Claude Code also works against a local self-signed certificate if you trust it (see TLS and public access).
Other MCP clients
Any client that speaks the MCP Streamable HTTP transport works. It either completes the OAuth flow (dynamic client registration, authorization code with PKCE) or sends Authorization: Bearer <MCP_AUTH_TOKEN>. A request without a token gets a 401 whose WWW-Authenticate header points to /.well-known/oauth-protected-resource/<endpoint>, where discovery starts.
Icons
Each endpoint sends its own icon in serverInfo, and /favicon.ico serves the project icon. Some clients, including claude.ai at the time of writing, show a generic icon for custom connectors regardless.
Configuration reference
Everything is configured with environment variables. The server checks all of them at startup and refuses to start with a list of every problem it found, so a typo never leaves it half configured.
General rules:
- An empty value counts as unset.
- Every variable can also be read from a file by appending
_FILEto its name, for exampleICLOUD_APP_PASSWORD_FILE=/run/secrets/icloud. A value set directly wins over the file, and a trailing newline in the file is ignored. Use this for secrets with Docker or Kubernetes secrets. - Booleans accept
true,false,1,0,yes,no,onandoff. - Lists are comma separated; spaces around names are ignored.
Server and HTTP
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
PUBLIC_URL | yes | https://icloud.example.com | External base URL clients use, without a path. Must be https; http is accepted only for localhost | |
BIND_ADDR | no | 0.0.0.0:8443 | 127.0.0.1:8443 | Listen address. It must listen on loopback (0.0.0.0 does) so --healthcheck can reach 127.0.0.1 |
TLS_CERT_PATH, TLS_KEY_PATH | no | /certs/fullchain.pem, /certs/privkey.pem | PEM certificate chain and private key. Both or neither; reloaded automatically when the files change. See TLS and public access | |
INSECURE_HTTP | no | false | true | Serve plain HTTP. Only when TLS is terminated in front of the server on a network you trust |
ALLOWED_ORIGINS | no | https://app.example.com | Extra Origin values accepted on MCP requests, comma separated. The PUBLIC_URL origin is always allowed | |
TRUSTED_PROXIES | no | 10.0.0.2,10.0.0.3 | Proxy IP addresses whose X-Forwarded-For is trusted for login rate limiting |
Services
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
ICLOUD_SERVICES | no | all (mail,calendar,contacts) | mail,calendar | Services to run. An unknown name or an empty list stops startup. A service that is not selected is not mounted and does not read its variables |
ICLOUD_READ_ONLY | no | none | contacts or all | Services whose write tools are hidden and refused, on their own endpoint and under /mcp. An unknown name stops startup. See Security |
ICLOUD_COMBINED | no | true | false | Serve the combined endpoint /mcp. With false, /mcp returns 404 |
OAuth and tokens
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
OAUTH_SIGNING_KEY | yes | output of openssl rand -hex 32 | Key that signs OAuth tokens, at least 32 bytes. Changing it signs every client out | |
OAUTH_ADMIN_PASSWORD | yes | a passphrase | Password for the server’s login page, at least 12 characters (the server warns below 15). Changing it signs every connector out | |
MCP_AUTH_TOKEN | no | output of openssl rand -hex 32 | Static bearer token accepted on every endpoint, for clients that can send a header. Treat it like the admin password. Must not contain exactly two dots |
iCloud account
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
ICLOUD_EMAIL | yes | you@icloud.com | Your Apple ID or iCloud address | |
ICLOUD_APP_PASSWORD | yes | xxxx-xxxx-xxxx-xxxx | App-specific password, used by every service |
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
ICLOUD_USERNAME | no | ICLOUD_EMAIL | you | IMAP and SMTP login, if it differs from the address |
ICLOUD_FROM_NAME | no | Alex Example | Display name on drafts | |
ICLOUD_ALLOW_SEND | no | false | true | Registers the send_draft tool. Without it nothing can be sent |
MAX_ATTACHMENT_BYTES | no | 10485760 (10 MiB) | 20971520 | Largest attachment get_attachment returns, and the largest decoded total of the attachments on one create_draft or create_reply_draft |
CACHE_MAX_BYTES | no | 67108864 (64 MiB) | 0 | In-memory cache of message bodies; 0 disables it |
IMAP_IDLE_TIMEOUT | no | 600 | 300 | Seconds before an idle IMAP connection is closed |
Calendar
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
CALENDAR_TIMEZONE | no | Europe/London | America/New_York | IANA time zone used to read and show times when no override is set. An unknown name stops startup |
ICLOUD_ALLOW_INVITES | no | false | true | Allows changing attendees (iCloud emails them) and registers respond_to_invitation |
CALDAV_URL | no | https://caldav.icloud.com | http://127.0.0.1:8080 | CalDAV entry point. Must be https, except a loopback address for testing |
Contacts
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
CONTACTS_TIMEZONE | no | UTC | Europe/London | IANA time zone that decides “today” for upcoming_dates. An unknown name stops startup |
CARDDAV_URL | no | https://contacts.icloud.com | http://127.0.0.1:8080 | CardDAV entry point. Must be https, except a loopback address for testing |
Logging, metrics and audit
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
LOG_LEVEL | no | info | info,mcp_icloud_contacts=debug | Log filter in tracing syntax. Targets: mcp_icloud_mail, mcp_icloud_calendar, mcp_icloud_contacts, mcp_server_core. The audit target always stays on at info |
LOG_FORMAT | no | json | pretty | json or pretty |
DEBUG | no | false | true | Debug logging with content redacted: tool arguments, result sizes, protocol command labels and status codes. Never IMAP protocol lines, bodies or credentials |
DEBUG_UNSAFE_LOG_CONTENT | no | false | true | Also logs full tool results and CalDAV and CardDAV bodies, which puts your mail, event and contact content in the logs. A warning is printed at startup. Credentials are never logged |
METRICS_ADDR | no | 0.0.0.0:9090 | Starts a separate plain-HTTP Prometheus listener. Keep it on an internal network | |
OTEL_EXPORTER_OTLP_ENDPOINT | no | https://otel.example.com | Exports traces over OTLP/HTTP. The standard OTEL_EXPORTER_OTLP_HEADERS is honoured. Nothing is exported unless it is set | |
AUDIT_LOG_PATH | no | /audit/audit.jsonl | File every write tool call is appended to as one JSON line (created with mode 0600). Audit lines are always logged too. See Security |
A complete example
ICLOUD_EMAIL=you@icloud.com
ICLOUD_APP_PASSWORD_FILE=/run/secrets/icloud_app_password
PUBLIC_URL=https://icloud.example.com
OAUTH_SIGNING_KEY_FILE=/run/secrets/oauth_signing_key
OAUTH_ADMIN_PASSWORD_FILE=/run/secrets/oauth_admin_password
MCP_AUTH_TOKEN_FILE=/run/secrets/mcp_auth_token
ICLOUD_SERVICES=mail,calendar,contacts
ICLOUD_READ_ONLY=mail
CALENDAR_TIMEZONE=Europe/London
CONTACTS_TIMEZONE=Europe/London
AUDIT_LOG_PATH=/audit/audit.jsonl
LOG_FORMAT=json
TLS and public access
claude.ai connects to your server from Anthropic’s infrastructure, so for claude.ai the server must be reachable from the internet over HTTPS with a publicly trusted certificate. Claude Code and other local clients can also use a certificate you trust yourself.
How the server picks its certificate
At startup, in this order:
INSECURE_HTTP=true: plain HTTP, no TLS. Use only when TLS is terminated in front of the server on a network you trust.TLS_CERT_PATHandTLS_KEY_PATHboth set: the server loads them, and reloads them automatically when the files change (for example after a certificate renewal). If either file is missing or invalid the server refuses to start; it never falls back silently. Setting only one of them is an error.- Neither set: the server generates an in-memory self-signed certificate for the
PUBLIC_URLhost,localhost,127.0.0.1and::1. It is never written to disk, changes on every restart, and its SHA-256 fingerprint is logged.
The self-signed certificate is fine for local use, for Claude Code with the certificate trusted, or for encrypting the hop from a tunnel or reverse proxy that skips verification. It does not work for claude.ai directly.
With certificate files the server sends Strict-Transport-Security: max-age=31536000. With the self-signed certificate or plain HTTP it does not, so a tunnel or proxy in front sets its own policy.
Option A: a Cloudflare tunnel
The simplest way to get a trusted public address. The tunnel’s edge certificate is trusted by claude.ai, and the server’s self-signed certificate protects the hop from the tunnel to the server. See the Docker Compose example in Getting started. In the tunnel’s public hostname settings, point the service to https://mcp-icloud:8443 and turn on No TLS Verify.
Set PUBLIC_URL to the tunnel hostname, for example https://icloud.example.com.
Option B: a reverse proxy
Run the server behind nginx, Caddy or Traefik with a trusted certificate at the proxy:
- Proxy to
https://<server>:8443(verification off, or pinned to the logged fingerprint), or setINSECURE_HTTP=trueif the proxy and server share a trusted network. - Pass the
Hostheader through unchanged and do not buffer responses (MCP uses streaming HTTP responses). - Set
TRUSTED_PROXIESto the proxy’s IP address so the server uses the client address fromX-Forwarded-Forfor login rate limiting.
Option C: the server holds the certificate
Give the server a certificate from Let’s Encrypt or another public CA:
docker run -d --name mcp-icloud -p 443:8443 \
-v /etc/letsencrypt/live/icloud.example.com:/certs:ro \
-e TLS_CERT_PATH=/certs/fullchain.pem \
-e TLS_KEY_PATH=/certs/privkey.pem \
--env-file app.env \
ghcr.io/trozz/mcp-icloud:latest
Renewals are picked up without a restart.
A trusted local certificate
For Claude Code or testing on your own machine.
With mkcert:
brew install mkcert
mkcert -install
mkcert -cert-file cert.pem -key-file key.pem localhost 127.0.0.1 ::1
TLS_CERT_PATH=$PWD/cert.pem TLS_KEY_PATH=$PWD/key.pem ./mcp-icloud
With openssl:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 825 \
-keyout key.pem -out cert.pem -subj "/CN=icloud.example.com" \
-addext "subjectAltName=DNS:icloud.example.com,DNS:localhost,IP:127.0.0.1"
Add cert.pem to the trust store of the machine running the client, or to NODE_EXTRA_CA_CERTS for Node based clients.
Endpoint /mail (or mail_<tool> under /mcp). The mail service reads and organises your iCloud mail over IMAP and saves drafts; it sends mail over SMTP only when you allow it.
Tools
| Tool | Kind | What it does | Key parameters |
|---|---|---|---|
list_mailboxes | read | Mailboxes with their role (Inbox, Sent, Drafts, Trash, Junk, Archive), total and unread counts | none |
search_messages | read | Search one mailbox, newest first; every filter is optional and they combine with AND. Returns summaries, the mailbox uidvalidity and next_cursor | mailbox (default INBOX), from, to, subject, text, since, before (YYYY-MM-DD), unseen, flagged, limit (1 to 100, default 25), cursor |
get_message | read | Headers, body and attachment list of one message. Does not mark it as read | mailbox (default INBOX), uid, format (text, html or raw), max_chars (default 20000) |
get_attachment | read | One attachment as an embedded base64 resource, up to MAX_ATTACHMENT_BYTES | mailbox (default INBOX), uid, index (from get_message) |
get_thread | read | The conversation a message belongs to, linked by Message-ID, References and In-Reply-To, from its mailbox and Sent, ordered by date | mailbox (default INBOX), uid |
mark_messages | write | Mark as read or unread, flagged or unflagged | mailbox (default INBOX), uids (1 to 100), seen, flagged, uidvalidity |
move_messages | write | Move messages to another mailbox | mailbox (default INBOX), uids, target_mailbox, uidvalidity |
delete_messages | write | Move messages to Trash; with permanent: true, messages already in Trash are removed for good | mailbox (default INBOX), uids, permanent, uidvalidity |
create_mailbox | write | Create a mailbox (folder) | name |
rename_mailbox | write | Rename a mailbox; its child mailboxes move with it. Refuses INBOX and system mailboxes (Sent, Drafts, Trash, Junk, Archive), and a new name that already exists or is a system mailbox name | name, new_name |
delete_mailbox | write | Delete an empty mailbox for good. Refuses INBOX, system mailboxes, and mailboxes that hold messages or child mailboxes | name |
create_draft | write | Save a new draft in Drafts, optionally with attachments. Nothing is sent | to, cc, bcc (name@example.com or Name <name@example.com>), subject, body_text, body_html, attachments (each filename, content_type, content_base64; at most 10, decoded total up to MAX_ATTACHMENT_BYTES) |
create_reply_draft | write | Save a threaded reply draft with the original quoted, optionally with attachments. Nothing is sent | mailbox (default INBOX), uid, body_text, reply_all, attachments (each filename, content_type, content_base64; at most 10, decoded total up to MAX_ATTACHMENT_BYTES) |
create_forward_draft | write | Save a forward draft, optionally with the original attachments. Nothing is sent | mailbox (default INBOX), uid, to, body_text, include_attachments (default true) |
send_draft | write | Send a draft over iCloud SMTP, save a copy to Sent and remove the draft. Registered only with ICLOUD_ALLOW_SEND=true | uid (in Drafts), uidvalidity |
Prompts
Prompts are ready-made requests a client can offer you. They tell the model which tools to call and in what order; they change nothing by themselves.
| Prompt | Arguments | What it does |
|---|---|---|
triage_inbox | mailbox (default INBOX), limit (1 to 100, default 25) | Summarises the unread messages and suggests replying, flagging, moving or deleting each one. Acts only on what you approve, writes reply drafts only when you ask, and never sends |
Arguments are text; a blank argument takes its default, and an invalid one (a limit outside 1 to 100) is refused. If a step needs a tool this server does not offer (a read-only service), the model is told to say so and skip it.
Addressing messages
Messages are addressed by mailbox plus UID. Listings also return the mailbox uidvalidity; pass it back to the changing tools. If the mailbox was rebuilt in the meantime (its uidvalidity changed), the tool refuses instead of touching a different message that now has the same UID.
Sending
- Without
ICLOUD_ALLOW_SEND=true,send_draftis not registered: clients do not see it, and the other tools can only save drafts. send_draftonly sends messages flagged as drafts in the Drafts mailbox, and sends one message at a time.ICLOUD_FROM_NAMEsets the display name on drafts; the address is alwaysICLOUD_EMAIL.create_draftandcreate_reply_drafttakeattachmentsas{filename, content_type, content_base64}objects:filename1 to 255 characters without/,\or control characters,content_typea plaintype/subtypesuch asapplication/pdf,content_base64standard base64. If any attachment is invalid or the decoded total is overMAX_ATTACHMENT_BYTES, nothing is saved. The result lists each attachment’sfilename,content_typeandsize.
iCloud notes
- When the server supports IMAP
MOVEit is used; otherwise moves use COPY, then flag and expunge only the moved UIDs (UIDPLUS), so other deleted messages in the mailbox are never expunged. - Message bodies are cached in memory (
CACHE_MAX_BYTES), so reading the same message twice does not download it again. - Idle IMAP connections are closed after
IMAP_IDLE_TIMEOUTseconds and reopened on demand.
Calendar
Endpoint /calendar (or calendar_<tool> under /mcp). The calendar service reads and edits your iCloud calendars over CalDAV.
Tools
| Tool | Kind | What it does | Key parameters |
|---|---|---|---|
list_calendars | read | Calendars that hold events: id, name, colour and whether they can be written | none |
list_events | read | Event occurrences in a range, recurring events expanded, sorted by start. At most 500 occurrences; truncated is true when more exist | start (default today at 00:00), end (default 7 days after start, at most 366 days after it), calendars, query, needs_response, timezone |
get_event | read | One event in full: description, recurrence (in words and as RRULE), excluded occurrences, attendees, organizer, alerts, URL | event_id, occurrence_start, timezone |
find_free_time | read | Free slots of at least duration_minutes, optionally inside working hours. Events marked free, cancelled events and invitations you declined are not busy | start, end, duration_minutes (1 to 10080), calendars, working_hours (start, end as HH:MM, days), timezone |
get_timezone | read | The active time zone, where it comes from, and the configured default | none |
set_timezone | write | Set the active time zone for later calls, for example while you travel; null returns to the default. Kept in memory until a restart | zone |
create_event | write | Create a timed or all-day event, optionally recurring, with alerts and (when allowed) attendees | calendar, summary, start and end, or start_date and end_date (inclusive), location, description, timezone, recurrence, alerts_minutes_before, attendees |
update_event | write | Change only the fields you pass, for the whole series, one occurrence, or this and following occurrences | event_id, scope (series, occurrence, this_and_following), occurrence_start, and any field of create_event |
delete_event | write | Delete the series, one occurrence, or this and following occurrences | event_id, scope, occurrence_start |
create_calendar | write | Create a calendar for events. The name must differ from every calendar’s name, ignoring case. Returns the calendar as list_calendars shows it | name, color (#RRGGBB) |
rename_calendar | write | Rename a calendar, change its colour, or both. The new name must not be another calendar’s name, ignoring case. Read-only calendars are refused | calendar, new_name, color (#RRGGBB; give at least one of the two) |
delete_calendar | write | Delete an empty calendar. Refused while it holds any event or other item, and for the only writable calendar. The emptiness check and the delete are two requests, so an event added in between is deleted with it | calendar |
respond_to_invitation | write | Accept, decline or tentatively accept an invitation; iCloud sends the reply to the organizer. Registered only with ICLOUD_ALLOW_INVITES=true | event_id, response (accepted, declined, tentative), occurrence_start |
Prompts
Prompts are ready-made requests a client can offer you. They tell the model which tools to call and in what order; they change nothing by themselves.
| Prompt | Arguments | What it does |
|---|---|---|
plan_week | start (YYYY-MM-DD, default today in the active time zone) | Lays out the seven days from start, points out overlapping and back-to-back events, lists invitations waiting for your reply and suggests free slots with find_free_time. Replies to an invitation only with the answer you give |
Arguments are text; a blank argument takes its default, and an invalid one (a start that is not a YYYY-MM-DD date) is refused. Replying to invitations needs ICLOUD_ALLOW_INVITES=true; without it the model is told to say so and skip that step.
Addressing events
Events are addressed by event_id as list_events returns it. One occurrence of a recurring event is additionally addressed by occurrence_start, exactly as list_events returns it. Every change is a conditional write: if the event changed in iCloud since it was read, the tool returns a conflict error instead of overwriting it.
Managing calendars
create_calendarmakes a calendar that holds events (not reminders) under a new id in your calendar home. Colours are given as#RRGGBB; iCloud stores them with an alpha channel, solist_calendarsshows them as#RRGGBBFF.- Calendar names are compared ignoring case:
create_calendarandrename_calendarreturn aconflicterror for a name another calendar already has. Changing only the case of a calendar’s own name is allowed. - Read-only calendars, such as subscribed or read-only shared calendars, cannot be renamed or deleted (
read_only). delete_calendaronly deletes an empty calendar: it first lists what the calendar holds and returns avalidationerror while it holds any event, reminder or other item. It also refuses to delete your only writable calendar.- The emptiness check and the delete are two separate requests to iCloud. An event added to the calendar between them, for example from your phone, is deleted together with the calendar.
Recurrence and alerts
recurrencetakesfreq(daily,weekly,monthly,yearly),interval,by_day(MOtoSU, with an ordinal such as2TUor-1FRfor monthly and yearly),by_month_day(1 to 31 or -1), and eitheruntilorcount.alerts_minutes_beforesets alerts in minutes before the start (0 to 40320). Onupdate_eventit replaces all alerts, and[]removes them.
Time zones
- Times are shown in the active zone, plus the event’s own zone when it differs. Times you pass without an offset are read in the active zone; times with an offset are used as given. All-day events are dates and never shift.
- The active zone is
CALENDAR_TIMEZONE(defaultEurope/London) unless overridden withset_timezone. The override lives in memory: a restart returns toCALENDAR_TIMEZONE. - Every time-based tool also takes
timezonefor one call. - A local time that does not exist (skipped by a daylight saving change) is rejected; an ambiguous one (a repeated hour) uses the earlier time.
Invitations
With ICLOUD_ALLOW_INVITES=false (the default), attendees are always shown and preserved, but the server will not change them: attendee arguments are refused, respond_to_invitation is not registered, and events you organize that have attendees, and invitations from others (apart from your own alerts), cannot be changed or deleted, because iCloud would email the attendees.
With ICLOUD_ALLOW_INVITES=true, adding or removing attendees makes iCloud send invitations or cancellations, and respond_to_invitation sends your reply to the organizer.
iCloud notes
- Reminders lists are not available: Apple no longer serves upgraded iCloud Reminders over CalDAV, so only event calendars appear.
- iCloud adds its own time zone definitions to stored events.
- Your credentials are only sent to
httpshosts undericloud.com(or the exactCALDAV_URLhost when overridden); redirects elsewhere are refused.
Contacts
Endpoint /contacts (or contacts_<tool> under /mcp). The contacts service reads and edits your iCloud address book over CardDAV, including groups (Lists in iOS Contacts).
Tools
| Tool | Kind | What it does | Key parameters |
|---|---|---|---|
search_contacts | read | Find contacts by name, nickname, organization, department, job title, email or related name (every word must match; case and accents are ignored), or by a phone number in any format. Without a query it lists everyone in name order | query, group_id, limit (1 to 100, default 25), offset |
get_contact | read | Everything about one contact: names, organization, emails, phones, addresses, URLs, birthday, dates, related names, social profiles, instant messaging, note and groups | id |
get_my_contact | read | Your own card (My Card in iOS Contacts), in full | none |
list_groups | read | Every group with its id, name and number of members | none |
upcoming_dates | read | Birthdays, anniversaries and other dates from today up to days ahead, soonest first, with the days from today and, when the year is known, the age or number of years | days (1 to 366, default 30) |
find_duplicate_contacts | read | Groups of contacts that look like the same person: a shared email, a shared phone number or the same full name, with the reasons, best contact to keep first | none |
export_vcard | read | A contact, or every member of a group, as vCard (.vcf) text exactly as iCloud stores it, returned as a text/vcard resource with a summary | id |
create_contact | write | Create a contact. Needs a given name, family name, display name or organization | contact fields (below) |
update_contact | write | Change only the fields you pass; "" clears one. A list you pass replaces that whole list | id plus contact fields |
delete_contact | write | Delete a contact and remove it from every group. Your own card cannot be deleted | id |
merge_contacts | write | Merge up to 10 contacts into one: missing details are added to the kept contact, group memberships move to it, and the others are deleted | keep_id, merge_ids |
copy_contact_photo | write | Copy one contact’s photo onto another, replacing its photo, for example before merging into a contact without one | from_id, to_id |
create_group | write | Create a group with optional members | name, member_ids |
update_group | write | Rename a group, add or remove members. Removing a contact from a group does not delete it | id, name, add_ids, remove_ids |
delete_group | write | Delete a group. Its contacts are kept | id |
Prompts
Prompts are ready-made requests a client can offer you. They tell the model which tools to call and in what order; they change nothing by themselves.
| Prompt | Arguments | What it does |
|---|---|---|
birthday_reminders | days (1 to 366, default 14) | Lists the birthdays and anniversaries coming up, by date, and offers to write a message for each. When the client also has the mail service, it can save the message as a draft; it never sends |
clean_up_duplicates | none | Runs find_duplicate_contacts and goes through the groups one at a time, explaining what a merge keeps and loses. Merges a group only after you confirm that group |
Arguments are text; a blank argument takes its default, and an invalid one (a days outside 1 to 366) is refused. On a read-only contacts service the prompts are still offered; the model is told that merging is not available.
Contact fields
given_name, family_name, middle_name, prefix, suffix, display_name, nickname, organization, department, job_title, birthday, note, and the lists emails, phones, urls (each [{value, label, preferred}]), addresses ([{street, city, region, postcode, country, label, preferred}]), dates ([{date, label}]) and related ([{name, label}]).
- Labels are plain words:
home,work,other,mobile,iPhone,main,pager,home fax,work fax,home page,anniversary, relations such asmotherorfriend, or any custom text. - Birthdays and dates are
YYYY-MM-DD, or--MM-DDwithout a year. display_namedefaults to the name parts joined, otherwise the organization.
Contacts and groups are addressed by id, as search_contacts and list_groups return it.
Lossless edits
Only the lines a change touches are rewritten. Photos, Apple’s own fields and anything this server does not show survive every edit, and entries you pass back exactly as get_contact returned them keep their original lines. Updates are conditional writes: if a contact changed in iCloud since it was read, the tool returns a conflict error instead of overwriting it.
Upcoming dates
- “Today” is the current date in
CONTACTS_TIMEZONE(defaultUTC). - The window includes today and the last day:
days: 1means today and tomorrow. - A date on 29 February shows on 28 February in other years. Dates without a year (
--MM-DD) have no age. - Birthdays are labelled
birthday; other dates keep their own label (anniversary, a custom text, ordatewithout one).
Duplicates and merging
find_duplicate_contactslinks contacts that share a normalised email address, a phone number (the same last 9 digits; numbers of 6 to 8 digits match on all their digits, shorter ones are ignored) or the same full name of at least two words. Linked contacts form one group. The first contact in each group (one with a photo, then the most details) is a sensiblekeep_id.merge_contactschecks everything before writing anything. It adds the emails, phones, URLs, addresses, dates and related names the kept contact lacks, fills its empty single fields, appends notes, moves group memberships to the kept contact, then deletes the merged contacts. Other fields of merged contacts (social profiles, instant messaging, categories, custom X- fields) are not kept. Your own card can be kept but never merged away.- Photos are never copied by a merge. When a merged contact had a photo and the kept one has none, the result lists it under
photo_not_kept; usecopy_contact_photofirst, or keep the contact with the photo. - If a later step fails (a group update or a delete), earlier steps are not undone; the result lists what was not done.
Export
export_vcardreturns a JSON summary (id,kind,display_name,cards,missing_members,bytes) and the vCard text as atext/vcardresource aticloud-contacts://export/<id>.vcf(a space,/or%in the id is percent-encoded).- A contact comes exactly as iCloud stores it: Apple’s folding, photo lines and own fields included. The server writes the card it holds back unchanged; the only differences readers could see are in text iCloud never stores:
BEGINandENDlines are written in capitals, line endings are CRLF, blank lines are dropped and the last line ends with a line break. - A group gives every member’s card, one after another, in the group’s member order, each member once. The group’s own card is not included. Member ids that are not readable contacts (deleted contacts, other groups, cards the server cannot parse) are skipped and listed under
missing_members. A group without readable members gives an empty file. - There is no size limit beyond what iCloud stores, and card text is never logged.
iCloud notes
- iCloud does not enforce
If-Matchon contact deletes, so deletes cannot be conditional. The server re-reads the address book at the start of every call and deletes immediately after, so only a change made on another device in those milliseconds could be lost. - Deleting a contact in iCloud does not remove it from groups; the server does that itself.
- iCloud stores cards exactly as sent; Apple’s own fields and folding are preserved.
- The server keeps an in-memory index of the address book and brings it up to date with a sync at the start of every call, so searches are fast and see changes made on your devices.
- Your credentials are only sent to
httpshosts undericloud.com(or the exactCARDDAV_URLhost when overridden); redirects elsewhere are refused.
Combined endpoint
Endpoint /mcp. It offers every enabled service’s tools in one MCP server, so one connector gives a client mail, calendar and contacts together. The per-service endpoints keep working unchanged.
Tool names
Under /mcp every tool is prefixed with its service: <service>_<tool>.
| Service | Example names |
|---|---|
mail_search_messages, mail_create_draft | |
| Calendar | calendar_list_events, calendar_create_event |
| Contacts | contacts_search_contacts, contacts_upcoming_dates |
Descriptions, parameters and annotations are the same as on the service’s own endpoint, so the Mail, Calendar and Contacts pages describe every tool. Only services selected by ICLOUD_SERVICES appear.
Prompts
Every service’s prompts are offered under /mcp with the same prefix as its tools, and their text names the prefixed tools (mail_search_messages here, search_messages on /mail).
| Prompt | Described on |
|---|---|
mail_triage_inbox | |
calendar_plan_week | Calendar |
contacts_birthday_reminders | Contacts |
contacts_clean_up_duplicates | Contacts |
Only services selected by ICLOUD_SERVICES contribute prompts.
Safety switches apply
- A read-only service (
ICLOUD_READ_ONLY) is read-only under/mcptoo: its write tools are hidden and refused. ICLOUD_ALLOW_SENDandICLOUD_ALLOW_INVITESdecide whethermail_send_draftandcalendar_respond_to_invitationexist, as on the service endpoints.- Audit lines name the real service and tool, with
"endpoint": "/mcp".
Sign-in
/mcp is its own OAuth protected resource (https://icloud.example.com/mcp, scope mcp), with metadata at /.well-known/oauth-protected-resource/mcp. A token issued for /mcp works only there, and a token for a service endpoint does not work on /mcp. Because /mcp is a resource of its own, an OAuth client must name the resource it wants (the resource parameter, which claude.ai sends), even when only one service runs. The static MCP_AUTH_TOKEN works on every endpoint.
Turning it off
/mcp is served whenever at least one service runs. Set ICLOUD_COMBINED=false to turn it off; /mcp then returns 404.
Choosing between them
- One connector for
/mcpis simplest, and lets the client combine services in one conversation. - Separate connectors let you enable services one by one in the client, and keep tool names short.
- Do not add both: every tool would appear twice.
Security
mcp-icloud holds an app-specific password with access to your mail, calendars and contacts, and is reachable from the internet. This page explains how access is controlled and what the server records.
Sign-in with OAuth
The server is its own OAuth 2.1 authorization server, built for MCP clients such as claude.ai:
sequenceDiagram
participant C as MCP client
participant S as mcp-icloud
participant U as You (browser)
C->>S: POST /contacts (no token)
S-->>C: 401, WWW-Authenticate: resource_metadata=.../oauth-protected-resource/contacts
C->>S: GET /.well-known/oauth-protected-resource/contacts
C->>S: GET /.well-known/oauth-authorization-server
C->>S: POST /register (dynamic client registration)
C->>U: open /authorize (PKCE S256, resource=.../contacts)
U->>S: OAUTH_ADMIN_PASSWORD on the login page
S-->>C: authorization code (redirect)
C->>S: POST /token (code + verifier)
S-->>C: access token (1 hour) and refresh token (30 days), audience /contacts
C->>S: POST /contacts with Bearer token
- Each endpoint (
/mail,/calendar,/contacts,/mcp) is its own OAuth protected resource (RFC 9728) with metadata at/.well-known/oauth-protected-resource/<endpoint>. - Tokens are bound to the endpoint they were issued for (RFC 8707): a token for
/mailis rejected on/calendar. - One login page and one
OAUTH_ADMIN_PASSWORDcover every endpoint. The login page names the service being connected. - Access tokens last 1 hour and refresh tokens 30 days, and every sign-in ends 90 days after it was made however often the connector refreshes; then you connect it again.
- Changing
OAUTH_ADMIN_PASSWORDends every sign-in: each connector asks you to connect again, without being added again. RotatingOAUTH_SIGNING_KEYalso signs every client out, and connectors must then be removed and added again. There is no per-client revocation.
The static token
MCP_AUTH_TOKEN is a single-owner shortcut for clients that can send a header. It is accepted on every endpoint, so treat it like the admin password: use a long random value (the server warns at startup about one shorter than 32 characters). Wrong tokens count towards the login rate limits below, so it cannot be guessed. A value with exactly two dots (the shape of a JWT) is refused at startup. Leave it unset if you only use claude.ai: then bearer tokens that are not this server’s are refused without counting, so they cannot lock you out.
Login rate limits
- At most 5 failed logins per minute per client address (an IPv6 /64 counts as one address), 25 per network (an IPv4 /24 or IPv6 /48) and 30 in total. With
MCP_AUTH_TOKENset, wrong bearer tokens count as failed logins too. Once the total is reached, every login and the static token are refused while failures keep arriving, so spreading guesses over many addresses gains nothing. - Trade-off: someone who keeps failing can lock you out of the login page and the static token for as long as they keep it up; OAuth tokens already issued keep working. This server’s own tokens, even expired ones, never count as failures. Without
MCP_AUTH_TOKEN, only failed logins on the login page count. - The client address is the TCP peer. Behind a reverse proxy, set
TRUSTED_PROXIESto the proxy addresses so the right-most untrustedX-Forwarded-Forentry is used; otherwise every request shares the proxy address and the per-client limit is effectively global.
Read-only services
ICLOUD_READ_ONLY makes services read-only on this server, for example ICLOUD_READ_ONLY=mail,contacts keeps calendar writable, and all covers every service.
- Clients see only the tools marked read-only.
- Prompts stay listed; a prompt step that needs a hidden tool tells the model to say so and skip it.
- Any other tool call returns
read_only: the <service> service is read-only on this server. - It applies on the service’s own endpoint and under
/mcp.
Combined with the defaults (ICLOUD_ALLOW_SEND=false, ICLOUD_ALLOW_INVITES=false), this lets you start with an assistant that can only look, and allow changes service by service.
Audit log
Every call of a tool that is not read-only, including refused ones, produces one audit line:
{"ts": "2026-10-18T10:00:00Z", "service": "contacts", "endpoint": "/contacts", "tool": "delete_contact", "outcome": "ok", "error_kind": null, "caller": "oauth:<client_id>", "subject": "owner", "elapsed_ms": 120}
outcomeisok,errororrefused(a read-only service);error_kindis the tool error kind such asconflict(read_onlyfor a refusal,protocolfor a malformed call), ornullon success.callerisoauth:<client_id>,oauthorstatic(theMCP_AUTH_TOKEN).subjectis who signed in:owner(withOAUTH_ADMIN_PASSWORD) orstatic(theMCP_AUTH_TOKEN).- Arguments, ids and results are never recorded.
- Lines go to the
auditlog target, which stays on atinfowhateverLOG_LEVELsays, and are appended toAUDIT_LOG_PATHwhen it is set (the file is created with mode 0600). - If the file cannot be written, the server logs a warning at most once a minute and the tool call still succeeds.
- In Docker, mount a volume for the file, for example
./audit:/auditwithAUDIT_LOG_PATH=/audit/audit.jsonl, so it survives container restarts.
What is never logged
- Passwords, tokens, the
Authorizationheader and IMAP protocol lines. - Message, event and contact content: bodies, names, numbers, addresses and notes. Contacts tools log contact ids only.
The only exception is DEBUG_UNSAFE_LOG_CONTENT=true, which writes content into the logs for debugging and prints a warning at startup. Credentials are never logged, even then.
Talking to Apple
- Every connection to Apple uses TLS with certificate verification.
- Calendar and contacts requests send your credentials only to
httpshosts undericloud.com(or the exactCALDAV_URLorCARDDAV_URLhost when overridden); redirects elsewhere are refused. - Revoking the app-specific password at account.apple.com cuts the server off immediately.
The container
The image is FROM scratch: the static binary and CA certificates only, running as UID 65532.
Operations
Health
GET /healthzreturnsokwithout authentication.mcp-icloud --healthcheckconnects to127.0.0.1on theBIND_ADDRport and exits 0 when/healthzanswers. The container image uses it as itsHEALTHCHECK, sodocker psshowshealthy.
Logs
- Logs go to stdout, as JSON by default (
LOG_FORMAT=prettyfor humans). - Every tool call, OAuth request and IMAP, SMTP, CalDAV and CardDAV request is a span with its name, outcome and duration. Startup logs one
service enabledline per service, with its URL and tools. LOG_LEVELtakestracingfilters, for exampleinfo,mcp_icloud_contacts=debug. Libraries that could log secrets are capped atinfowhatever you set.DEBUG=trueadds tool arguments, result sizes, protocol command labels and status codes, without content.DEBUG_UNSAFE_LOG_CONTENT=trueadds full content. Use it only briefly; see Security.
Metrics
METRICS_ADDR (for example 0.0.0.0:9090) starts a separate plain-HTTP Prometheus listener. Keep it on an internal network. Metrics:
| Metric | What it counts |
|---|---|
mcp_tool_calls_total, mcp_tool_duration_seconds | Tool calls by tool and outcome, and their duration |
imap_commands_total, imap_command_duration_seconds, imap_reconnects_total | IMAP commands and reconnects |
smtp_sends_total | Sent messages |
caldav_requests_total, caldav_request_duration_seconds | CalDAV requests by method and status class |
carddav_requests_total, carddav_request_duration_seconds | CardDAV requests by method and status class |
oauth_requests_total, oauth_login_failures_total | OAuth requests and failed logins |
message_cache_hits_total, message_cache_misses_total | Message body cache |
http_requests_total | HTTP requests by route and status |
Traces
OTEL_EXPORTER_OTLP_ENDPOINT, with the standard OTEL_EXPORTER_OTLP_HEADERS when your collector needs them, exports traces over OTLP/HTTP. Nothing is exported unless it is set.
Audit file
With AUDIT_LOG_PATH, the audit file only grows: one short line per write. Rotate it with a tool that truncates in place, such as logrotate with copytruncate. See Security for the line format.
Upgrading
- Docker: pull the new image and recreate the container (
docker compose pull && docker compose up -d). The container is stateless. - Binary: replace the file and restart.
- Connectors stay signed in across restarts and upgrades as long as
OAUTH_SIGNING_KEYandOAUTH_ADMIN_PASSWORDstay the same, except where the release notes say otherwise. Every sign-in also ends 90 days after it was made. - Release notes list every change; see Releases.
Upgrading from 1.1 or earlier
The first release built on mcp-server-core changes sign-in:
- Every connector signs in once more: tokens from earlier versions lack the sign-in version and time the server now checks. Each connector asks you to connect again; you do not need to add it again. Claude Code with
MCP_AUTH_TOKENis unaffected. - From then on, every sign-in ends 90 days after it was made, and changing
OAUTH_ADMIN_PASSWORDends them all. - The server refuses to start with an
MCP_AUTH_TOKENthat has exactly two dots, and warns about anOAUTH_ADMIN_PASSWORDshorter than 15 characters. - Audit lines gain a
subjectfield (see Security). - With certificate files (
TLS_CERT_PATH), responses carryStrict-Transport-Security.
Backups
There is nothing to back up: iCloud holds all data, and the server keeps only caches in memory. Keep your configuration and secrets somewhere safe.
Resource use
Memory holds the message body cache (CACHE_MAX_BYTES, default 64 MiB), the calendar list, and the contacts index (a few kilobytes per contact). The container needs no writable filesystem apart from the audit volume.
Troubleshooting
The server does not start
The server lists every configuration problem at startup and exits. Common ones:
| Message | Fix |
|---|---|
PUBLIC_URL is required | Set PUBLIC_URL to the external https address, without a path |
OAUTH_SIGNING_KEY must be at least 32 bytes | Use openssl rand -hex 32 |
OAUTH_ADMIN_PASSWORD must be at least 12 characters, not only spaces | Choose a longer password; with 15 or more characters the startup warning goes too |
MCP_AUTH_TOKEN must not have the shape of a JWT (three dot-separated parts) | Use a value without exactly two dots; openssl rand -hex 32 is fine |
ICLOUD_SERVICES contains an unknown service: ... | Use names from mail, calendar, contacts |
CALENDAR_TIMEZONE "..." is not an IANA time zone name such as Europe/London | Use a name from the IANA list, such as America/New_York |
..._FILE: cannot read ... | The secret file is missing or unreadable by UID 65532 in the container |
claude.ai cannot connect
- The server must be reachable from the internet with a publicly trusted certificate. A self-signed certificate works only behind a tunnel or proxy; see TLS and public access.
- With a Cloudflare tunnel, the public hostname must point to
https://mcp-icloud:8443with No TLS Verify on. PUBLIC_URLmust be exactly the address claude.ai uses. OAuth metadata and token audiences are built from it.
A connector stops working with 401
- Every sign-in ends 90 days after it was made, and changing
OAUTH_ADMIN_PASSWORDends them all: connect the connector again. - After upgrading from 1.1 or earlier, every connector signs in once more (see Upgrading).
- Rotating
OAUTH_SIGNING_KEYsigns every client out: remove and add the connector again. - A token for one endpoint is not accepted on another. Make sure the connector URL is the endpoint it signed in to.
“iCloud rejected the login”
The app-specific password was revoked or mistyped, or ICLOUD_EMAIL (ICLOUD_USERNAME for mail) is wrong. Create a new app-specific password and restart the server.
Tool errors
Calendar and contacts tool errors start with a kind, for example conflict: ...:
| Kind | Meaning |
|---|---|
conflict | The item changed in iCloud since it was read. Read it again and retry |
not_found | The id or mailbox does not exist (any more) |
validation | An argument is wrong; the message says which |
read_only | The service is read-only on this server (ICLOUD_READ_ONLY), or the calendar or item cannot be written |
auth | iCloud rejected the login |
quota | The iCloud storage quota is full |
upstream | iCloud returned an unexpected answer; usually temporary |
internal | An unexpected error inside the server; check the log and report it if it repeats |
Mail tool errors have no kind. They are plain messages that say what went wrong, such as mailbox not found: ... or UIDVALIDITY of ... changed ...; re-run search_messages to get fresh uids. The conflict and quota kinds therefore do not appear for mail.
Contacts look out of date
The contacts index syncs at the start of every call, so changes on your devices appear on the next call. If the startup log shows contacts discovery failed; will retry on the first tool call, check the app-specific password and network access to contacts.icloud.com.
More detail
Set LOG_LEVEL=info,mcp_icloud_mail=debug (or mcp_icloud_calendar, mcp_icloud_contacts) or DEBUG=true and reproduce the problem. These do not log your content. When reporting an issue, include the version (mcp-icloud --version) and the log lines, and check that they contain nothing private.
Architecture
Request flow
flowchart LR
C1["MCP client<br/>/mail"] --> T
C2["MCP client<br/>/calendar"] --> T
C3["MCP client<br/>/contacts"] --> T
C4["MCP client<br/>/mcp"] --> T
T["HTTPS<br/>tunnel, proxy or direct"] --> S
subgraph S["mcp-icloud binary (:8443)"]
direction TB
P["Public routes<br/>/healthz, /favicon.ico<br/>/.well-known/*, /register<br/>/authorize, /token"]
G["Bearer check per endpoint<br/>token audience or static token"]
W["Policy wrapper<br/>read-only and audit"]
M["Mail service"]
K["Calendar service"]
A["Contacts service<br/>in-memory index"]
X["Combined /mcp<br/>prefixed tool names"]
D["mcp-icloud-dav<br/>shared WebDAV client"]
G --> W
W --> M
W --> K
W --> A
W --> X
X --> M
X --> K
X --> A
K --> D
A --> D
end
M -->|"IMAP / SMTP"| IC1["imap.mail.me.com<br/>smtp.mail.me.com"]
D -->|"CalDAV"| IC2["caldav.icloud.com<br/>partition host"]
D -->|"CardDAV"| IC3["contacts.icloud.com<br/>partition host"]
Crates
| Crate | Role |
|---|---|
mcp-icloud-dav | The shared WebDAV client (scoped credentials, redirects, retries), multistatus parsing, home discovery, and the lossless content-line layer used for iCalendar and vCard |
mcp-icloud-mail | The mail service: IMAP client and pool, message parsing, draft building, SMTP sending |
mcp-icloud-calendar | The calendar service: discovery, recurrence expansion, event editing, invitations, time zones |
mcp-icloud-contacts | The contacts service: address book discovery, the synced in-memory index, vCard reading and lossless patching, search, dates and duplicates |
mcp-icloud | The binary: lists the services and builds the server from them |
The server itself comes from mcp-server-core, an open-source crate shared with other MCP servers: configuration reading, the OAuth authorization server, bearer guards, sign-in limits, TLS, logging, metrics, the read-only and audit wrapper, service selection and the combined endpoint, and the Service trait every service implements. The workspace Cargo.toml pins its version.
The services do not depend on each other. Anything that combines them lives in mcp-server-core or the binary.
A contact edit, end to end
sequenceDiagram
participant C as Client
participant S as mcp-icloud /contacts
participant I as iCloud CardDAV
C->>S: tools/call update_contact
S->>I: REPORT sync-collection (last token)
I-->>S: changed hrefs and etags, new token
S->>I: REPORT addressbook-multiget (changed cards only)
S->>S: patch only the touched lines
S->>I: PUT card (If-Match: etag)
I-->>S: 204 and a new ETag (stored byte for byte)
S-->>C: the contact as stored
Design choices
- No database. iCloud is the only store. Caches (message bodies, the calendar list, the contacts index) live in memory and are rebuilt after a restart.
- Lossless edits. Calendar and contact edits rewrite only the properties they change and keep every other line, including Apple’s own folding, byte for byte.
- Conditional writes. Updates send
If-Matchso a change made elsewhere is never overwritten silently. CardDAV deletes are the exception, because iCloud ignoresIf-Matchon them; see Contacts. - Safe defaults. Sending mail and changing invitations need explicit switches, and any service can be made read-only.
Contributing
Build and test
You need Rust 1.99.1 (the workspace pins it).
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
cargo deny check
bash scripts/test-next-version.sh
Live smoke tests against your own account are ignored by default:
ICLOUD_EMAIL=... ICLOUD_APP_PASSWORD=... cargo test -p mcp-icloud-mail --test live_icloud -- --ignored --nocapture
ICLOUD_EMAIL=... ICLOUD_APP_PASSWORD=... cargo test -p mcp-icloud-calendar --test live_icloud -- --ignored --nocapture --test-threads=1
ICLOUD_EMAIL=... ICLOUD_APP_PASSWORD=... cargo test -p mcp-icloud-contacts --test live_icloud -- --ignored --nocapture --test-threads=1
The calendar round trip creates one clearly named event in December 2030 and always deletes it. The contacts round trips create contacts named ZZ MCP Probe and a group ZZ MCP Probe Group with made-up data (the merge test merges two such contacts) and always delete them. The invitation test runs only when ICLOUD_TEST_ATTENDEE names an address you control.
This documentation
The site is built with mdBook from docs/. To preview it:
cargo install --locked mdbook@0.5.4 mdbook-mermaid@0.17.1 lychee@0.24.2
bash scripts/build-docs.sh
mdbook serve docs --open
scripts/build-docs.sh builds the book and checks every internal link and anchor. A test in the mcp-icloud crate checks that every environment variable the server reads appears in the configuration reference and every registered tool appears on its service page, so the docs cannot fall behind the code.
Adding a service
Each service is a library crate that implements the Service trait from mcp-server-core (mcp_server_core::service):
- Create
crates/mcp-icloud-<name>as a library. Read its own variables (plusICLOUD_EMAILandICLOUD_APP_PASSWORD) inpub fn from_env(env: &mut ServiceEnv) -> Option<Arc<dyn Service>>, reporting problems throughenv.errors. Never read the server variables. - Return the service’s MCP server from
handler(): the defaultrouter()serves it at/<name>and the combined endpoint offers it at/mcp. Overriderouter()withmcp_server_core::service::mcp_serviceonly to do work when the service is mounted, as calendar and contacts do to start discovery. Mark tools that only read withread_only_hint = true. Service names are lowercase path segments without_(the combined endpoint uses_to separate the service from the tool name), andmcpis reserved. - Add the crate to
crates/mcp-icloud/Cargo.tomland one line toSERVICESincrates/mcp-icloud/src/lib.rs. - Document its tools on a new page under
docs/src/services/and its variables indocs/src/configuration.md.
Routing, OAuth metadata, token audiences, ICLOUD_SERVICES, read-only mode, audit, the combined endpoint and the release pipeline pick the new service up without further changes.
Pull requests
- Branch from
developasfeature/<name>,bugfix/<name>orhotfix/<name>, and open the pull request againstdevelop. - Use conventional commit messages (
feat:,fix:,docs:,test:,chore:,refactor:). - Test data must be synthetic: never copy real mail, events or contacts into fixtures.
- CI runs fmt, clippy, tests, cargo-deny, a Docker build, the script tests and the documentation build.
Releases
- Work happens on
feature/*andbugfix/*branches merged intodevelopby pull request. - Every merge into
developthat touches the server publishes a pre-releasevX.Y.Z-dev.N: a GitHub pre-release and image tagsX.Y.Z-dev.Nanddevelop. - A pull request from
developintomainpublishes a releasevX.Y.Zwhen merged: image tagsX.Y.Z,X.Y,Xandlatest, binaries for Linux x86_64 and arm64 and macOS arm64 with checksums, and this documentation site. - The version bump comes from the
developtomainpull request: amajor,minororpatchlabel, otherwise[major],[minor]or[patch]in its title, otherwise patch. - Only merge commits are allowed, so
developandmainnever diverge. Pull requests intomainmust come fromdevelopin this repository.
Images are published to ghcr.io/trozz/mcp-icloud. Pin a version tag such as ghcr.io/trozz/mcp-icloud:1.2.3 in production, and read the release notes before upgrading.
Repository setup (maintainers, once)
gh label create major --color B60205 --description "Release: major version bump"
gh label create minor --color FBCA04 --description "Release: minor version bump"
gh label create patch --color 0E8A16 --description "Release: patch version bump"
- Under Settings > Actions > General > Workflow permissions, the workflows must be allowed to grant
contents: write,packages: write,id-token: write,attestations: writeandpages: write(the jobs request them explicitly). - Under Settings > Pages, the source must be GitHub Actions for this site to publish.
- After the first image is published, set the
mcp-icloudpackage visibility to public under Packages for anonymous pulls.