Overview
iCloud MCP is an MCP server, hosted on Cloudflare Workers, that gives an AI assistant native tool access to iCloud Mail, Calendar, and Contacts. It speaks the real protocols (IMAP over a raw TLS socket, CalDAV and CardDAV over HTTPS) directly from a single Worker. No Mac mini running AppleScript, no sync service, no third party holding my credentials.
I built it for a specific reason: I was tired of being Claude’s clipboard. Three milestones in, it has grown past that. Anyone on an allow list I control can sign in with their own Apple ID, isolated from every other person signed in. The server keeps a searchable memory of recent mail so Claude can find things by what they were about, not just what words they contained. And, inside a boundary I get to define, it can now act on its own: flag a message or place a draft reply to it, on a schedule, with nobody watching. 47 tools across mail, calendar, contacts, and diagnostics, still one Worker, still nothing leaving it that shouldn’t.
The repo is public and MIT licensed. Every account-specific value lives in configuration you supply.
How It Fits Together
Every request still follows the same path it always has: an MCP client authenticates with an OAuth 2.1 bearer token, the Worker checks it before any of my code runs, and a fresh MCP server is built for that one request. What’s new sits underneath it. Each signed-in person now has their own Durable Object, holding their connection lease, their recall index, their autonomy credential, and their rules. It never sees an address or a password, and it never opens a socket itself. The only new thing it can do is call back into the Worker’s own /mcp endpoint on its own alarm, every fifteen minutes, through exactly four tools. That loop is the whole autonomous layer.
The Hard Part: IMAP at the Edge
Cloudflare Workers is the only platform that offers raw TCP sockets and HTTP fetch from the same edge runtime, which is what it takes to speak IMAP, CalDAV, and CardDAV from one process. What it does not offer is an IMAP client. Every candidate on npm (imapflow, node-imap, emailjs) depends on Node’s net and tls at the source level and dies on Workers.
So the project speaks the protocol itself: LOGIN, EXAMINE, LIST, FETCH, SEARCH, APPEND, and a parser built for real-world IMAP responses (literals with embedded CRLF, UID versus sequence-number confusion, servers that hang forever on non-synchronizing literals). Plus an RFC 5322 message builder written from scratch, because every builder on npm drags in a Node-era dependency tree to avoid a few hundred lines of mechanical string templating.
Two constraints drove the connection design. Workers caps an invocation at six simultaneous connections, and that budget counts KV reads and outbound fetches too; the OAuth token check spends one before any mail code runs. And iCloud’s own connection ceiling is undocumented. I deliberately never measured it, because a self-inflicted lockout would take Mail.app down on my own devices. So every tool call is connect, login, work, logout, close. No pooling, and a hard internal cap of three.
Anyone On The List, Nobody Sees Anyone Else’s Data
The first milestone was built for one account. The second rebuilt identity from the ground up so several people can use the same Worker, each with their own Apple ID and their own app-specific password, without a line of code changing behavior for the owner along the way. A user’s id is the SHA-256 hash of their Apple ID; the address itself never appears in a KV key, an R2 key, a token, or a URL. Every store a person’s data can land in (staged attachments, calendar confirmation tokens, the recall index) is scoped to that hash and checked against the signed-in user, never a value taken off the request. Who may sign in is an allow list I set. Everyone else is refused before Apple is ever contacted.
Find Mail By Meaning, Without Lying About It
IMAP SEARCH is substring matching. It has no notion of “that recruiter thread about the staff role.” So the server now keeps a second, much smaller index: a numeric fingerprint of each recent message’s subject, sender, and opening lines, stored in Vectorize and searched by meaning.
The tool contract took more thought than the index itself. mail_find is exhaustive: an empty answer means no such mail exists in that folder. mail_recall is ranked and best-effort: an empty answer means nothing scored high enough. Those are different promises, and a model reasons differently depending on which one it thinks it’s getting. If one tool silently answered both kinds of question, Claude would keep making the exhaustive-search inference against a result set that can’t support it, and eventually tell me I have no mail from someone I do. So the name carries the guarantee, not the backend. mail_recall also never hands back a quotable body, only an id and a snippet; every real read still goes through IMAP. A stale or deleted reference fails loudly at that point instead of being presented as the message.
Acting On Its Own, Inside A Shrinking Box
Every person who signs in gets an autonomy credential, a second, narrower OAuth grant sealed to their own user id. With no rules written, it does nothing. Write a rule, and every 15 minutes a job wakes up, asks the server what’s new in the inbox, matches each message’s sender and subject against your rules, and either flags a match or places a draft reply to its sender. It reaches mail exactly the way any other client would: a real tool call through the Worker’s own endpoint, taking the same connection lease as everyone else. It can name only four tools, and there is no code path that lets it reach a fifth. Adding a rule is previewed first, the same as every other write in the system, and the preview names the exact condition and the exact action before anything is saved.
The scope here is deliberately small. It can flag something or draft something. It cannot send anything, move anything, or delete anything, and there are no default rules, so a fresh account runs nothing until you write something.
Safety as Architecture
An AI with your mailbox is a security exercise before it is a product. The safety rules here are structural, not aspirational:
- No sending, ever. There is no SMTP code anywhere in the repository. Claude roughs out a starting point; it lands in the Drafts folder; the human rewrites it in their own words and hits send. That includes the reply a rule drafts on its own: still a draft, still waiting for a person.
- Reading never marks read. Every mailbox opens in read-only
EXAMINEmode and every fetch uses the peeking form. Triage needed the first real exception to that: one narrow, separate mutating path, used only by the tools that explicitly mark, flag, or move a message, and every other read path is untouched. - Zero logging. IMAP’s
LOGINcommand carries the password inline in the command stream, so there is no named field a log redactor could target. Credentials are consumed by write-only helpers that return nothing. - Dangerous paths are unspeakable. The one function allowed to open a socket takes no parameters at all. No host, no port, no transport mode.
- A source scanner enforces all of it. It started at about 1,150 lines checking a handful of rules. It’s grown to roughly 6,300, running from both the test suite and a pre-commit hook, because every new capability (triage, contact writes, the rules job) got its own mechanical rule the day it shipped, not a promise in a doc.
Preview-Then-Commit, Everywhere Now
Calendar writes never happened on the first call, and that pattern now covers everything that changes state: updating or deleting an event, answering an invitation, creating or editing a contact, moving mail to another folder or the archive, deleting a draft. Each returns a human-readable preview plus a signed, single-use confirmation token (HMAC-SHA-256, five-minute TTL, bound to a canonical hash of the exact change), and nothing happens until that token comes back unaltered. A calendar write still carries an If-Match ETag, so a race returns a clean conflict instead of clobbering someone else’s edit.
Your Inbox Is Hostile Input
Email is stranger-authored content that arrives before you ask for it. A stranger’s meeting invite is a live prompt-injection vector sitting in a list response. Every tool response fences third-party content behind a random per-response nonce with an explicit notice: instructions found inside subjects, senders, bodies, and filenames are data to report, never commands to follow. No write tool in the project may take a message or event identifier as the source of an attendee list, and the rules job matches only sender, sender domain, and subject words, never anything a message body asks it to do.
Tech Stack
- Cloudflare Workers — the entire server. Raw TCP for IMAP, fetch for DAV, one deployment, no origin.
- Durable Objects (SQLite) — one object per signed-in person: connection lease, recall ledger, autonomy credential, rules and their job.
- TypeScript — strict, exact-pinned dependencies, 86 source modules.
- MCP SDK v2 (
@modelcontextprotocol/server2.0.0) viacreateMcpHandler— a stateless handler; a fresh server is built for every request. - OAuth 2.1 —
@cloudflare/workers-oauth-providergates every request before project code runs. Dynamic client registration, PKCE required. - Vectorize + Workers AI — the recall index and its embedding model, partitioned per person, fails closed if a query somehow arrives with no namespace.
- KV — OAuth state, DAV discovery cache, spent confirmation tokens, in separate namespaces.
- R2 — attachment staging with presigned uploads, and the sealed, time-boxed links that hand a saved attachment to Claude’s own local file bridge.
- Zod — every tool input schema, doing double duty as the API contract.
- Vitest, via
vitest-pool-workers, so tests run inside the real workerd runtime against the real wrangler config, not a Node mock.
Outcomes
- Live in production, gated by OAuth 2.1, on a custom domain;
workers.devand preview URLs stay disabled by design - 47 tools across mail, calendar, contacts, recall, change detection, and rules — up from 23 at first launch
- 71,300 lines of source, 143,000 lines of tests, across 86 source modules and 110 test files
- The safety scanner grew from ~1,150 lines to ~6,300 as every new write, every new autonomy path, got its own mechanical rule
- Three milestones: single-account MVP in 14 days (6 phases), multi-user identity rebuilt with zero behavior change for the owner until the switch flipped (7 phases), then this one (17 phases in 6 days, 2 of them cut outright after measuring rather than guessing — a Reminders integration died when the spike showed ten of eleven lists are invisible over CalDAV, and a planned extra auth layer was dropped for adding risk without buying anything real)
- Open source and reproducible — every account-specific value is a placeholder in a tracked example config; MIT licensed