Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

EndpointServiceWhat it does
/mailMailSearch, read, organise and draft email; optionally send
/calendarCalendarList, search, create, edit and delete events, including recurring ones; create, rename and delete calendars; find free time; optionally manage invitations
/contactsContactsSearch, read, create, edit and delete contacts; groups (iOS Lists); upcoming birthdays and anniversaries; find and merge duplicates
/mcpCombinedEvery enabled service’s tools in one connector, named <service>_<tool>

Highlights

  • A single static Rust binary, also shipped as a FROM scratch container 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:

PurposeProtocolHost
Reading and organising mailIMAPimap.mail.me.com:993
Sending mailSMTP with STARTTLSsmtp.mail.me.com:587
CalendarsCalDAVcaldav.icloud.com, then your account’s partition host such as p99-caldav.icloud.com
ContactsCardDAVcontacts.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

  1. Sign in at account.apple.com.
  2. Open Sign-In and Security, then App-Specific Passwords.
  3. Create one named mcp-icloud and copy it. It looks like xxxx-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 with openssl rand -hex 32 and 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,calendar runs only some services (default: all). A service that is not enabled is not mounted and does not read its configuration.
  • ICLOUD_READ_ONLY=contacts (or all) hides and refuses the write tools of those services.
  • ICLOUD_ALLOW_SEND=true enables sending mail, and ICLOUD_ALLOW_INVITES=true enables 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.

EndpointConnector URL
Mailhttps://icloud.example.com/mail
Calendarhttps://icloud.example.com/calendar
Contactshttps://icloud.example.com/contacts
All serviceshttps://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).

  1. In claude.ai open Settings, then Connectors, then Add custom connector.
  2. Enter the connector URL, for example https://icloud.example.com/contacts.
  3. 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 _FILE to its name, for example ICLOUD_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, on and off.
  • Lists are comma separated; spaces around names are ignored.

Server and HTTP

VariableRequiredDefaultExampleDescription
PUBLIC_URLyeshttps://icloud.example.comExternal base URL clients use, without a path. Must be https; http is accepted only for localhost
BIND_ADDRno0.0.0.0:8443127.0.0.1:8443Listen address. It must listen on loopback (0.0.0.0 does) so --healthcheck can reach 127.0.0.1
TLS_CERT_PATH, TLS_KEY_PATHno/certs/fullchain.pem, /certs/privkey.pemPEM certificate chain and private key. Both or neither; reloaded automatically when the files change. See TLS and public access
INSECURE_HTTPnofalsetrueServe plain HTTP. Only when TLS is terminated in front of the server on a network you trust
ALLOWED_ORIGINSnohttps://app.example.comExtra Origin values accepted on MCP requests, comma separated. The PUBLIC_URL origin is always allowed
TRUSTED_PROXIESno10.0.0.2,10.0.0.3Proxy IP addresses whose X-Forwarded-For is trusted for login rate limiting

Services

VariableRequiredDefaultExampleDescription
ICLOUD_SERVICESnoall (mail,calendar,contacts)mail,calendarServices 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_ONLYnononecontacts or allServices whose write tools are hidden and refused, on their own endpoint and under /mcp. An unknown name stops startup. See Security
ICLOUD_COMBINEDnotruefalseServe the combined endpoint /mcp. With false, /mcp returns 404

OAuth and tokens

VariableRequiredDefaultExampleDescription
OAUTH_SIGNING_KEYyesoutput of openssl rand -hex 32Key that signs OAuth tokens, at least 32 bytes. Changing it signs every client out
OAUTH_ADMIN_PASSWORDyesa passphrasePassword for the server’s login page, at least 12 characters (the server warns below 15). Changing it signs every connector out
MCP_AUTH_TOKENnooutput of openssl rand -hex 32Static 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

VariableRequiredDefaultExampleDescription
ICLOUD_EMAILyesyou@icloud.comYour Apple ID or iCloud address
ICLOUD_APP_PASSWORDyesxxxx-xxxx-xxxx-xxxxApp-specific password, used by every service

Mail

VariableRequiredDefaultExampleDescription
ICLOUD_USERNAMEnoICLOUD_EMAILyouIMAP and SMTP login, if it differs from the address
ICLOUD_FROM_NAMEnoAlex ExampleDisplay name on drafts
ICLOUD_ALLOW_SENDnofalsetrueRegisters the send_draft tool. Without it nothing can be sent
MAX_ATTACHMENT_BYTESno10485760 (10 MiB)20971520Largest attachment get_attachment returns, and the largest decoded total of the attachments on one create_draft or create_reply_draft
CACHE_MAX_BYTESno67108864 (64 MiB)0In-memory cache of message bodies; 0 disables it
IMAP_IDLE_TIMEOUTno600300Seconds before an idle IMAP connection is closed

Calendar

VariableRequiredDefaultExampleDescription
CALENDAR_TIMEZONEnoEurope/LondonAmerica/New_YorkIANA time zone used to read and show times when no override is set. An unknown name stops startup
ICLOUD_ALLOW_INVITESnofalsetrueAllows changing attendees (iCloud emails them) and registers respond_to_invitation
CALDAV_URLnohttps://caldav.icloud.comhttp://127.0.0.1:8080CalDAV entry point. Must be https, except a loopback address for testing

Contacts

VariableRequiredDefaultExampleDescription
CONTACTS_TIMEZONEnoUTCEurope/LondonIANA time zone that decides “today” for upcoming_dates. An unknown name stops startup
CARDDAV_URLnohttps://contacts.icloud.comhttp://127.0.0.1:8080CardDAV entry point. Must be https, except a loopback address for testing

Logging, metrics and audit

VariableRequiredDefaultExampleDescription
LOG_LEVELnoinfoinfo,mcp_icloud_contacts=debugLog 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_FORMATnojsonprettyjson or pretty
DEBUGnofalsetrueDebug logging with content redacted: tool arguments, result sizes, protocol command labels and status codes. Never IMAP protocol lines, bodies or credentials
DEBUG_UNSAFE_LOG_CONTENTnofalsetrueAlso 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_ADDRno0.0.0.0:9090Starts a separate plain-HTTP Prometheus listener. Keep it on an internal network
OTEL_EXPORTER_OTLP_ENDPOINTnohttps://otel.example.comExports traces over OTLP/HTTP. The standard OTEL_EXPORTER_OTLP_HEADERS is honoured. Nothing is exported unless it is set
AUDIT_LOG_PATHno/audit/audit.jsonlFile 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:

  1. INSECURE_HTTP=true: plain HTTP, no TLS. Use only when TLS is terminated in front of the server on a network you trust.
  2. TLS_CERT_PATH and TLS_KEY_PATH both 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.
  3. Neither set: the server generates an in-memory self-signed certificate for the PUBLIC_URL host, localhost, 127.0.0.1 and ::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 set INSECURE_HTTP=true if the proxy and server share a trusted network.
  • Pass the Host header through unchanged and do not buffer responses (MCP uses streaming HTTP responses).
  • Set TRUSTED_PROXIES to the proxy’s IP address so the server uses the client address from X-Forwarded-For for 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.

Mail

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

ToolKindWhat it doesKey parameters
list_mailboxesreadMailboxes with their role (Inbox, Sent, Drafts, Trash, Junk, Archive), total and unread countsnone
search_messagesreadSearch one mailbox, newest first; every filter is optional and they combine with AND. Returns summaries, the mailbox uidvalidity and next_cursormailbox (default INBOX), from, to, subject, text, since, before (YYYY-MM-DD), unseen, flagged, limit (1 to 100, default 25), cursor
get_messagereadHeaders, body and attachment list of one message. Does not mark it as readmailbox (default INBOX), uid, format (text, html or raw), max_chars (default 20000)
get_attachmentreadOne attachment as an embedded base64 resource, up to MAX_ATTACHMENT_BYTESmailbox (default INBOX), uid, index (from get_message)
get_threadreadThe conversation a message belongs to, linked by Message-ID, References and In-Reply-To, from its mailbox and Sent, ordered by datemailbox (default INBOX), uid
mark_messageswriteMark as read or unread, flagged or unflaggedmailbox (default INBOX), uids (1 to 100), seen, flagged, uidvalidity
move_messageswriteMove messages to another mailboxmailbox (default INBOX), uids, target_mailbox, uidvalidity
delete_messageswriteMove messages to Trash; with permanent: true, messages already in Trash are removed for goodmailbox (default INBOX), uids, permanent, uidvalidity
create_mailboxwriteCreate a mailbox (folder)name
rename_mailboxwriteRename 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 namename, new_name
delete_mailboxwriteDelete an empty mailbox for good. Refuses INBOX, system mailboxes, and mailboxes that hold messages or child mailboxesname
create_draftwriteSave a new draft in Drafts, optionally with attachments. Nothing is sentto, 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_draftwriteSave a threaded reply draft with the original quoted, optionally with attachments. Nothing is sentmailbox (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_draftwriteSave a forward draft, optionally with the original attachments. Nothing is sentmailbox (default INBOX), uid, to, body_text, include_attachments (default true)
send_draftwriteSend a draft over iCloud SMTP, save a copy to Sent and remove the draft. Registered only with ICLOUD_ALLOW_SEND=trueuid (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.

PromptArgumentsWhat it does
triage_inboxmailbox (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_draft is not registered: clients do not see it, and the other tools can only save drafts.
  • send_draft only sends messages flagged as drafts in the Drafts mailbox, and sends one message at a time.
  • ICLOUD_FROM_NAME sets the display name on drafts; the address is always ICLOUD_EMAIL.
  • create_draft and create_reply_draft take attachments as {filename, content_type, content_base64} objects: filename 1 to 255 characters without /, \ or control characters, content_type a plain type/subtype such as application/pdf, content_base64 standard base64. If any attachment is invalid or the decoded total is over MAX_ATTACHMENT_BYTES, nothing is saved. The result lists each attachment’s filename, content_type and size.

iCloud notes

  • When the server supports IMAP MOVE it 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_TIMEOUT seconds and reopened on demand.

Calendar

Endpoint /calendar (or calendar_<tool> under /mcp). The calendar service reads and edits your iCloud calendars over CalDAV.

Tools

ToolKindWhat it doesKey parameters
list_calendarsreadCalendars that hold events: id, name, colour and whether they can be writtennone
list_eventsreadEvent occurrences in a range, recurring events expanded, sorted by start. At most 500 occurrences; truncated is true when more existstart (default today at 00:00), end (default 7 days after start, at most 366 days after it), calendars, query, needs_response, timezone
get_eventreadOne event in full: description, recurrence (in words and as RRULE), excluded occurrences, attendees, organizer, alerts, URLevent_id, occurrence_start, timezone
find_free_timereadFree slots of at least duration_minutes, optionally inside working hours. Events marked free, cancelled events and invitations you declined are not busystart, end, duration_minutes (1 to 10080), calendars, working_hours (start, end as HH:MM, days), timezone
get_timezonereadThe active time zone, where it comes from, and the configured defaultnone
set_timezonewriteSet the active time zone for later calls, for example while you travel; null returns to the default. Kept in memory until a restartzone
create_eventwriteCreate a timed or all-day event, optionally recurring, with alerts and (when allowed) attendeescalendar, summary, start and end, or start_date and end_date (inclusive), location, description, timezone, recurrence, alerts_minutes_before, attendees
update_eventwriteChange only the fields you pass, for the whole series, one occurrence, or this and following occurrencesevent_id, scope (series, occurrence, this_and_following), occurrence_start, and any field of create_event
delete_eventwriteDelete the series, one occurrence, or this and following occurrencesevent_id, scope, occurrence_start
create_calendarwriteCreate a calendar for events. The name must differ from every calendar’s name, ignoring case. Returns the calendar as list_calendars shows itname, color (#RRGGBB)
rename_calendarwriteRename a calendar, change its colour, or both. The new name must not be another calendar’s name, ignoring case. Read-only calendars are refusedcalendar, new_name, color (#RRGGBB; give at least one of the two)
delete_calendarwriteDelete 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 itcalendar
respond_to_invitationwriteAccept, decline or tentatively accept an invitation; iCloud sends the reply to the organizer. Registered only with ICLOUD_ALLOW_INVITES=trueevent_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.

PromptArgumentsWhat it does
plan_weekstart (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_calendar makes 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, so list_calendars shows them as #RRGGBBFF.
  • Calendar names are compared ignoring case: create_calendar and rename_calendar return a conflict error 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_calendar only deletes an empty calendar: it first lists what the calendar holds and returns a validation error 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

  • recurrence takes freq (daily, weekly, monthly, yearly), interval, by_day (MO to SU, with an ordinal such as 2TU or -1FR for monthly and yearly), by_month_day (1 to 31 or -1), and either until or count.
  • alerts_minutes_before sets alerts in minutes before the start (0 to 40320). On update_event it 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 (default Europe/London) unless overridden with set_timezone. The override lives in memory: a restart returns to CALENDAR_TIMEZONE.
  • Every time-based tool also takes timezone for 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 https hosts under icloud.com (or the exact CALDAV_URL host 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

ToolKindWhat it doesKey parameters
search_contactsreadFind 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 orderquery, group_id, limit (1 to 100, default 25), offset
get_contactreadEverything about one contact: names, organization, emails, phones, addresses, URLs, birthday, dates, related names, social profiles, instant messaging, note and groupsid
get_my_contactreadYour own card (My Card in iOS Contacts), in fullnone
list_groupsreadEvery group with its id, name and number of membersnone
upcoming_datesreadBirthdays, 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 yearsdays (1 to 366, default 30)
find_duplicate_contactsreadGroups 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 firstnone
export_vcardreadA contact, or every member of a group, as vCard (.vcf) text exactly as iCloud stores it, returned as a text/vcard resource with a summaryid
create_contactwriteCreate a contact. Needs a given name, family name, display name or organizationcontact fields (below)
update_contactwriteChange only the fields you pass; "" clears one. A list you pass replaces that whole listid plus contact fields
delete_contactwriteDelete a contact and remove it from every group. Your own card cannot be deletedid
merge_contactswriteMerge up to 10 contacts into one: missing details are added to the kept contact, group memberships move to it, and the others are deletedkeep_id, merge_ids
copy_contact_photowriteCopy one contact’s photo onto another, replacing its photo, for example before merging into a contact without onefrom_id, to_id
create_groupwriteCreate a group with optional membersname, member_ids
update_groupwriteRename a group, add or remove members. Removing a contact from a group does not delete itid, name, add_ids, remove_ids
delete_groupwriteDelete a group. Its contacts are keptid

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.

PromptArgumentsWhat it does
birthday_remindersdays (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_duplicatesnoneRuns 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 as mother or friend, or any custom text.
  • Birthdays and dates are YYYY-MM-DD, or --MM-DD without a year.
  • display_name defaults 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 (default UTC).
  • The window includes today and the last day: days: 1 means 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, or date without one).

Duplicates and merging

  • find_duplicate_contacts links 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 sensible keep_id.
  • merge_contacts checks 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; use copy_contact_photo first, 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_vcard returns a JSON summary (id, kind, display_name, cards, missing_members, bytes) and the vCard text as a text/vcard resource at icloud-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: BEGIN and END lines 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-Match on 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 https hosts under icloud.com (or the exact CARDDAV_URL host 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>.

ServiceExample names
Mailmail_search_messages, mail_create_draft
Calendarcalendar_list_events, calendar_create_event
Contactscontacts_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).

PromptDescribed on
mail_triage_inboxMail
calendar_plan_weekCalendar
contacts_birthday_remindersContacts
contacts_clean_up_duplicatesContacts

Only services selected by ICLOUD_SERVICES contribute prompts.

Safety switches apply

  • A read-only service (ICLOUD_READ_ONLY) is read-only under /mcp too: its write tools are hidden and refused.
  • ICLOUD_ALLOW_SEND and ICLOUD_ALLOW_INVITES decide whether mail_send_draft and calendar_respond_to_invitation exist, 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 /mcp is 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 /mail is rejected on /calendar.
  • One login page and one OAUTH_ADMIN_PASSWORD cover 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_PASSWORD ends every sign-in: each connector asks you to connect again, without being added again. Rotating OAUTH_SIGNING_KEY also 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_TOKEN set, 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_PROXIES to the proxy addresses so the right-most untrusted X-Forwarded-For entry 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}
  • outcome is ok, error or refused (a read-only service); error_kind is the tool error kind such as conflict (read_only for a refusal, protocol for a malformed call), or null on success.
  • caller is oauth:<client_id>, oauth or static (the MCP_AUTH_TOKEN).
  • subject is who signed in: owner (with OAUTH_ADMIN_PASSWORD) or static (the MCP_AUTH_TOKEN).
  • Arguments, ids and results are never recorded.
  • Lines go to the audit log target, which stays on at info whatever LOG_LEVEL says, and are appended to AUDIT_LOG_PATH when 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:/audit with AUDIT_LOG_PATH=/audit/audit.jsonl, so it survives container restarts.

What is never logged

  • Passwords, tokens, the Authorization header 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 https hosts under icloud.com (or the exact CALDAV_URL or CARDDAV_URL host 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 /healthz returns ok without authentication.
  • mcp-icloud --healthcheck connects to 127.0.0.1 on the BIND_ADDR port and exits 0 when /healthz answers. The container image uses it as its HEALTHCHECK, so docker ps shows healthy.

Logs

  • Logs go to stdout, as JSON by default (LOG_FORMAT=pretty for 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 enabled line per service, with its URL and tools.
  • LOG_LEVEL takes tracing filters, for example info,mcp_icloud_contacts=debug. Libraries that could log secrets are capped at info whatever you set.
  • DEBUG=true adds tool arguments, result sizes, protocol command labels and status codes, without content.
  • DEBUG_UNSAFE_LOG_CONTENT=true adds 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:

MetricWhat it counts
mcp_tool_calls_total, mcp_tool_duration_secondsTool calls by tool and outcome, and their duration
imap_commands_total, imap_command_duration_seconds, imap_reconnects_totalIMAP commands and reconnects
smtp_sends_totalSent messages
caldav_requests_total, caldav_request_duration_secondsCalDAV requests by method and status class
carddav_requests_total, carddav_request_duration_secondsCardDAV requests by method and status class
oauth_requests_total, oauth_login_failures_totalOAuth requests and failed logins
message_cache_hits_total, message_cache_misses_totalMessage body cache
http_requests_totalHTTP 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_KEY and OAUTH_ADMIN_PASSWORD stay 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_TOKEN is unaffected.
  • From then on, every sign-in ends 90 days after it was made, and changing OAUTH_ADMIN_PASSWORD ends them all.
  • The server refuses to start with an MCP_AUTH_TOKEN that has exactly two dots, and warns about an OAUTH_ADMIN_PASSWORD shorter than 15 characters.
  • Audit lines gain a subject field (see Security).
  • With certificate files (TLS_CERT_PATH), responses carry Strict-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:

MessageFix
PUBLIC_URL is requiredSet PUBLIC_URL to the external https address, without a path
OAUTH_SIGNING_KEY must be at least 32 bytesUse openssl rand -hex 32
OAUTH_ADMIN_PASSWORD must be at least 12 characters, not only spacesChoose 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/LondonUse 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:8443 with No TLS Verify on.
  • PUBLIC_URL must 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_PASSWORD ends them all: connect the connector again.
  • After upgrading from 1.1 or earlier, every connector signs in once more (see Upgrading).
  • Rotating OAUTH_SIGNING_KEY signs 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: ...:

KindMeaning
conflictThe item changed in iCloud since it was read. Read it again and retry
not_foundThe id or mailbox does not exist (any more)
validationAn argument is wrong; the message says which
read_onlyThe service is read-only on this server (ICLOUD_READ_ONLY), or the calendar or item cannot be written
authiCloud rejected the login
quotaThe iCloud storage quota is full
upstreamiCloud returned an unexpected answer; usually temporary
internalAn 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

CrateRole
mcp-icloud-davThe shared WebDAV client (scoped credentials, redirects, retries), multistatus parsing, home discovery, and the lossless content-line layer used for iCalendar and vCard
mcp-icloud-mailThe mail service: IMAP client and pool, message parsing, draft building, SMTP sending
mcp-icloud-calendarThe calendar service: discovery, recurrence expansion, event editing, invitations, time zones
mcp-icloud-contactsThe contacts service: address book discovery, the synced in-memory index, vCard reading and lossless patching, search, dates and duplicates
mcp-icloudThe 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-Match so a change made elsewhere is never overwritten silently. CardDAV deletes are the exception, because iCloud ignores If-Match on 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):

  1. Create crates/mcp-icloud-<name> as a library. Read its own variables (plus ICLOUD_EMAIL and ICLOUD_APP_PASSWORD) in pub fn from_env(env: &mut ServiceEnv) -> Option<Arc<dyn Service>>, reporting problems through env.errors. Never read the server variables.
  2. Return the service’s MCP server from handler(): the default router() serves it at /<name> and the combined endpoint offers it at /mcp. Override router() with mcp_server_core::service::mcp_service only to do work when the service is mounted, as calendar and contacts do to start discovery. Mark tools that only read with read_only_hint = true. Service names are lowercase path segments without _ (the combined endpoint uses _ to separate the service from the tool name), and mcp is reserved.
  3. Add the crate to crates/mcp-icloud/Cargo.toml and one line to SERVICES in crates/mcp-icloud/src/lib.rs.
  4. Document its tools on a new page under docs/src/services/ and its variables in docs/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 develop as feature/<name>, bugfix/<name> or hotfix/<name>, and open the pull request against develop.
  • 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/* and bugfix/* branches merged into develop by pull request.
  • Every merge into develop that touches the server publishes a pre-release vX.Y.Z-dev.N: a GitHub pre-release and image tags X.Y.Z-dev.N and develop.
  • A pull request from develop into main publishes a release vX.Y.Z when merged: image tags X.Y.Z, X.Y, X and latest, binaries for Linux x86_64 and arm64 and macOS arm64 with checksums, and this documentation site.
  • The version bump comes from the develop to main pull request: a major, minor or patch label, otherwise [major], [minor] or [patch] in its title, otherwise patch.
  • Only merge commits are allowed, so develop and main never diverge. Pull requests into main must come from develop in 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: write and pages: 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-icloud package visibility to public under Packages for anonymous pulls.