{
  "markdown": "<p align=\"center\">\n  <img src=\"design/cupertino-lockup.svg\" alt=\"Cupertino\" width=\"560\">\n</p>\n\n# Cupertino\n\n**Put your agent to work in your everyday Apple apps.**\n\nMCP servers for the Apple apps already on your Mac, and the signed app that grants them their\npermissions once instead of once each — for any agent that speaks MCP, not for one host.\n\n> **Unofficial.** Not affiliated with Apple. These drive the apps that are already on your Mac.\n\n## In use\n\n> Look at how I write in my work inbox, then draft this reply in the same voice.\n\n> Pull together everything about the Atlas launch from my mail, my notes and my calendar. What do I\n> still owe people?\n\n> Turn the action items from yesterday's client thread into reminders, due Friday.\n\nEvery one of those carries a constraint — an account, a date bound, a filter. That is the part the\nnaive `osascript` path answers in 74 seconds or answers wrongly, and the reason a server earns its\nplace: it holds what the model would otherwise re-derive every session. The measurements are in\n[docs/verify.md](docs/verify.md); what the alternatives cost is in\n[docs/alternatives.md](docs/alternatives.md).\n\nThe last one needs the write gate open on Reminders. Writes are off per surface until you turn them\non, and the toggle decides whether the mutating tools are registered at all — an agent with writes\noff cannot see that they exist.\n\n## Surfaces\n\n| Surface   | Package                                    | Status                                                                                         |\n| --------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- |\n| Mail      | [`packages/mail`](packages/mail)           | implemented — 21 tools, search/read/attachments + gated writes                                 |\n| Notes     | [`packages/notes`](packages/notes)         | implemented — 13 tools, search/read/attachments + gated writes                                 |\n| Reminders | [`packages/reminders`](packages/reminders) | implemented — 11 tools, lists/search/dates + gated writes                                      |\n| Calendar  | [`packages/calendar`](packages/calendar)   | implemented — 10 tools, ranges/search/free-time + gated writes                                 |\n| Contacts  | [`packages/contacts`](packages/contacts)   | implemented — 7 tools, resolves handles to names + gated writes                                |\n| Messages  | [`packages/messages`](packages/messages)   | implemented — 9 tools, chats/search/counts/decoded text + gated send and codes                 |\n| Safari    | [`packages/safari`](packages/safari)       | implemented — 14 tools, history/tabs/reading list/page reads + gated writes and codes          |\n| Maps      | [`packages/maps`](packages/maps)           | implemented — 10 tools, favourites/Guides/recents + gated writes                               |\n| Screen    | —                                          | implemented — 3 tools, ScreenCaptureKit; served in-app, no npm package; off until switched on  |\n| Sound     | —                                          | implemented — 10 tools, volume/routing/speech + gated recording; in-app; off until switched on |\n| —         | [`packages/core`](packages/core)           | shared: the osascript boundary, TCC-aware errors, ro SQLite                                    |\n\n**Screen and Sound arrive switched off.** Every surface that brokers an Apple app is on when\nCupertino is installed; those two are not, because Screen Recording and the microphone are\nper-process grants that reach past the surface being brokered. Switch them on in the surface list if\nyou want them.\n\nEach surface is its own server, so a host loads only the tools it wants. Every surface that brokers\nan Apple app is also its own npm package; `screen` is not, and could not be — it brokers\nScreenCaptureKit rather than an app, the Screen Recording grant lives in the app, so the app serves\nit in-process and a published package could do nothing. See [docs/screen.md](docs/screen.md).\n\nThey share one bundle and one Full Disk Access grant, which is the whole reason they live together\n— see [docs/distribution.md](docs/distribution.md).\n\n## Quick start\n\nEvery server that brokers an Apple app is on npm and runs straight from `npx` — for Claude Code, a\n`.mcp.json` beside your project:\n\n```json\n{\n  \"mcpServers\": {\n    \"apple-mail\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@mgcrea/mcp-apple-mail\"]\n    },\n    \"apple-notes\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@mgcrea/mcp-apple-notes\"]\n    }\n  }\n}\n```\n\nThe packages are MIT and need no licence key. What they do need is a permission, and on npm you\ngrant it to whatever launches them — your editor, your terminal — which is the trade the signed\n[`Cupertino.app`](https://cupertino.mgcrea.io) exists to avoid: one Full Disk Access grant held by a\nnotarized binary, instead of one per host. See [docs/licensing.md](docs/licensing.md).\n\nOr run them from source:\n\n```bash\ngit clone https://github.com/mgcrea/cupertino.git\ncd cupertino\npnpm install\npnpm build\n```\n\nthen point your host at `packages/<surface>/dist/cli.js` by absolute path.\n\nWrites are off unless you ask for them — see [Configuration](#configuration).\n\nRunning through the menu bar app instead routes every server through the bridge, so Full Disk\nAccess is granted to Cupertino rather than to whichever editor spawned the server:\n\n```bash\nmake run      # build Cupertino.app, point it at packages/*/dist, launch it\nmake smoke    # handshake every server through the bridge\n```\n\nThe repo's checked-in [`.mcp.json`](.mcp.json) is wired for that path. `make` on its own lists\nevery target.\n\nNote the different server names. Wired by hand as above, a server is `apple-mail` and runs under\nwhatever grant its host process has. Wired by Cupertino it is `cupertino-mail`, because that entry\npoints at the app's bridge and runs under the app's grant. Two names for two deployments, and you\ncan have both. The app only ever touches its own `cupertino-*` keys — an `apple-mail` entry\nbelonging to some other server is left alone.\n\nCupertino is machine configuration, not a project dependency, so it belongs in a per-user config:\none file each, and the equivalent of `--scope user` everywhere. Deliberately **not** `--scope\nproject`, which writes an `.mcp.json` meant to be committed — that entry is an absolute path into a\nbundle on one Mac, backed by one person's Full Disk Access grant, and it would be useless to a\nteammate and unwise to offer them.\n\nAll seven are written by the app, and nothing has to be pasted into a terminal. Six keep strict JSON\nand are merged into as dictionaries — five under `mcpServers`, Visual Studio Code under `servers` in\n`User/mcp.json`, which is a different file from the JSONC `settings.json` it was confused with for\ntwo releases. The seventh, `~/.codex/config.toml`, is TOML full of hand-written prose and structure,\nso it is never re-serialised: `ClientWiringTOML` replaces the lines that hold MCP servers and quotes\nevery other byte verbatim. ChatGPT has no row of its own because it is not a separate client — the\nChatGPT app, the Codex CLI and the Codex IDE extension all read that same file, which is the row\ncalled \"ChatGPT & Codex\".\n\nClaude Code's `~/.claude.json` is written directly, and it is the one config where that deserves a\nparagraph: it holds this machine's credentials beside ninety-odd project blocks, and Claude Code\nwrites to it while it runs. So every write copies the file to `~/.claude.json.cupertino-backup`\nfirst, lands through a temp file and an atomic swap, and is refused outright if anything touched the\nfile between the read and the swap — in which case the app re-reads and merges again. What no\nwriter can promise is the last word: a session holding its own copy of the file will win, and\n`claude mcp add` is the same read-modify-write from another process, which is why handing over that\nline was never the safer option. The difference is that the app reads the file it wrote, so a\nclobbered entry shows up as **Not configured** the next time you open Settings, with a button that\nfixes it.\n\nThis repo's own [`.mcp.json`](.mcp.json) is the exception, and it names its servers\n`cupertino-*-dev` on purpose: it points at `apps/apple/.build`, so working on the app means having\nthe development build and the installed one side by side. Claude Code reports servers of the same\nname in two scopes as a conflict rather than picking one, so the suffix is what keeps both usable.\n\n`Cupertino.app` needs **macOS 26 or later** — its icon is an Icon Composer bundle, which nothing\nolder can render. The servers themselves are plain Node and carry no such floor; only the menu bar\napp does.\n\n## Permissions\n\nTwo separate macOS grants, and they land on **whatever process launched the server** — your\neditor, your terminal, or Cupertino — never on Mail, Notes or Reminders themselves.\n\n| Grant                                     | Needed for                                                      |\n| ----------------------------------------- | --------------------------------------------------------------- |\n| **Full Disk Access**                      | the index lane: Mail search, attachment bytes                   |\n| **Automation** (per target app, prompted) | the Apple Events lane: accounts, mailboxes, all writes          |\n| **Contacts** (prompted)                   | the Contacts surface — its store is not behind Full Disk Access |\n\nSystem Settings → Privacy & Security → Full Disk Access → add the launching app, then restart it.\nGranting it to Mail.app does nothing; the reader needs the permission, not Mail.\n\n**What works without Full Disk Access:**\n\n| Surface   | Without the grant                                                                                                                                                                                                                                                                                                               |\n| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Mail      | accounts, mailboxes and writes only — search falls back to Apple Events at ~74 s                                                                                                                                                                                                                                                |\n| Notes     | **fully usable** below roughly 5k notes; only attachment bytes need the grant                                                                                                                                                                                                                                                   |\n| Reminders | usable, but all-day dates and subtasks need the store — the container cannot even be listed without it                                                                                                                                                                                                                          |\n| Calendar  | **nothing.** The only surface with no Apple Events read path fast enough to be a fallback — one 90-day range query costs 3.4 s — so every read needs the grant. Writes still work.                                                                                                                                              |\n| Contacts  | **nothing** — but it does not want Full Disk Access. Its store sits behind the separate Contacts permission, which macOS _prompts_ for rather than making you find a settings pane. Writes need Automation on top.                                                                                                              |\n| Messages  | **nothing at all.** No Apple Events read path exists — Messages answers \"Application isn't running\" even while running — so there is no second lane and no degraded mode. A send can still be attempted, but with no store to pick the target from or reconcile against, it usually cannot be addressed at all.                 |\n| Safari    | **live tabs, and only those.** The one surface whose lanes are not fallbacks for each other: Apple Events sees what is open now, the file lane sees everything else. History, bookmarks and the Reading List all need the grant.                                                                                                |\n| Maps      | **nothing at all.** The only surface with no second lane by construction: Maps ships no scripting dictionary, so there is no Apple Events fallback to degrade to. Without the grant this server returns an error rather than an empty list, because an empty list of favourites reads exactly like a person who has saved none. |\n\nTools that need the index don't disappear when it's missing — the tool list is a pure function of\n`allowWrites` and nothing else, because MCP clients cache it. They return a structured `degraded`\nresult naming what's absent. `apple_mail_diagnostics` / `apple_notes_diagnostics` report which lane\nis live and how to grant what's missing.\n\n## Tools\n\nRead tools are always registered. Write tools are **invisible unless writes are enabled** — not\nmerely refused.\n\n### Mail\n\n| Always available                                                | Write-gated                                                                |\n| --------------------------------------------------------------- | -------------------------------------------------------------------------- |\n| `search_messages` `list_messages` `count_messages` `get_thread` | `set_message_flags` `move_messages` `delete_messages` `check_for_new_mail` |\n| `get_message` `get_message_source` `list_attachments`           | `send_message` `reply_to_message` `forward_message`                        |\n| `list_accounts` `list_mailboxes` `diagnostics`                  | `save_attachment`                                                          |\n\n### Notes\n\n| Always available                                          | Write-gated                                                              |\n| --------------------------------------------------------- | ------------------------------------------------------------------------ |\n| `list_notes` `search_notes` `get_note` `list_attachments` | `create_note` `update_note` `move_note` `delete_notes` `save_attachment` |\n| `list_accounts` `list_folders` `diagnostics`              |                                                                          |\n\n### Reminders\n\n| Always available                                                | Write-gated                                                                                  |\n| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |\n| `list_reminders` `search_reminders` `get_reminder` `list_lists` | `create_reminder` `update_reminder` `complete_reminders` `move_reminders` `delete_reminders` |\n| `list_accounts` `diagnostics`                                   |                                                                                              |\n\n### Calendar\n\n| Always available                                           | Write-gated                                   |\n| ---------------------------------------------------------- | --------------------------------------------- |\n| `list_events` `search_events` `get_event` `list_calendars` | `create_event` `update_event` `delete_events` |\n| `list_accounts` `diagnostics`                              |                                               |\n\n`list_events` expands repeating events, so a weekly standup is returned once per week. Every result\ncarries the window the expansion is known to cover, and sets `truncated` when a range runs past it\nrather than coming back short — a short list of events is indistinguishable from a free afternoon.\n\n### Contacts\n\n| Always available                                                  | Write-gated                       |\n| ----------------------------------------------------------------- | --------------------------------- |\n| `resolve_handles` `search_contacts` `list_contacts` `get_contact` | `create_contact` `update_contact` |\n| `diagnostics`                                                     |                                   |\n\n`resolve_handles` turns phone numbers and email addresses into names, which is what makes the\nMessages surface readable at all. Read `status` on each result rather than assuming a name came\nback: `unknown` is common and not an error, and `ambiguous` means two contacts share the number, so\nno name is returned rather than a guess.\n\n**There is no delete tool.** Contacts' scripting dictionary has no delete command of any kind, and\nthis surface's writes go through Apple Events because its store is read-only by policy. That was\ntrue of every surface until Maps, which has no scripting dictionary to go through and writes SQL\ninstead. See [docs/contacts.md](docs/contacts.md).\n\n### Messages\n\n| Always available                               | Write-gated    |\n| ---------------------------------------------- | -------------- |\n| `list_chats` `list_messages` `search_messages` | `send_message` |\n| `get_message` `diagnostics`                    |                |\n\n**One write tool, because the dictionary has one usable command.** `sdef` lists `send`, `login` and\n`logout`; the other two would sign the user out of iMessage on every device they own. There is no\nedit, delete, mark-as-read or reaction verb to expose.\n\nThis is the only surface where Apple Events is a **write lane and nothing else** — every read\nthrough it fails, so with `APPLE_MESSAGES_ALLOW_WRITES` off no Apple Event is ever sent and no\nAutomation grant is ever requested.\n\n`send_message` prefers a `chatRef` from `list_chats` over a raw handle, because Messages refuses to\nenumerate participants for a script: an existing conversation is the only target that can be\naddressed reliably. Apple Events hands back no identifier for what it sent, so the result is\nreconciled against `chat.db` — `reconciliation: \"matched\"` carries a real message ref, and\n`\"pending\"` means Messages accepted the send but has not written the row yet. **Pending is not a\nfailure, and retrying it sends the message twice.**\n\nAbout 3% of messages across all history keep their text only in an archived `NSArchiver` blob that\nSQL cannot reach — and Apple stopped writing the plain column in early 2026, so for anything recent\nit is ~100%. This server decodes them; `textSource` on every result says which lane answered. See\n[docs/messages.md](docs/messages.md).\n\n### Safari\n\n| Always available                                   | Write-gated |\n| -------------------------------------------------- | ----------- |\n| `search_history` `get_page` `list_tabs`            | — none      |\n| `list_bookmarks` `list_reading_list` `diagnostics` |             |\n\n**Read-only, and the write column is empty on purpose.** Opening a URL or adding to the Reading\nList is an Apple Event that navigates a real, visible browser, and no write on this surface was\never probed.\n\n`list_tabs` is the only tool in the whole bundle that works without Full Disk Access — it needs an\nAutomation grant instead, and Safari has to be running. Ask for the tab marked `frontmost` to get\nthe one the user is looking at: `active` means selected in its own window, so two open windows\nproduce two active tabs. A tab's `history` field being null means **not found in history**, never\n\"never visited\": the match rate is a property of the tab set rather than of the surface, measured at\n60.7%, 55.3% and 8.3% on three real runs. Single-page apps are the main reason — a page reached by\npushState commits no history row at all. Each match reports `historyMatch`, and even an `exact` one\ncan be a different site when the address is reused, as any `localhost` URL is.\n\n**Page content comes from a Safari extension, and only for websites you allow it on.**\n`apple_safari_read_page` returns a page as readable text or raw HTML. It is a snapshot taken when\nthe page loaded rather than a live read, so every result says when it was captured and how old it\nis — a page you have navigated away from still answers, and saying so is the point.\n\n**There is still no `do JavaScript` tool.** That verb needs \"Allow JavaScript from Apple Events\", a\ndeveloper-menu toggle which is not a TCC grant and whose own state cannot be read, so diagnostics\ncould never tell you in advance whether it would work — and it is global: any process able to send\nApple Events could then run script in any tab. Safari exposes no `AXWebArea` for page content\neither, so the Accessibility lane that reaches Mail's composer does not reach a web page. The\nextension is the only route, and it is the one Safari scopes per website.\nSee [docs/safari.md](docs/safari.md).\n\n### Maps\n\n| Always available                                                 | Write-gated                      |\n| ---------------------------------------------------------------- | -------------------------------- |\n| `list_favorites` `list_collections` `list_collection_places`     | `add_favorite` `remove_favorite` |\n| `list_unfiled_places` `list_recents` `search_places` `get_place` |                                  |\n| `diagnostics`                                                    |                                  |\n\nFavourites, collections (Guides) and recents, from a Core Data store under Full Disk Access —\nwith real coordinates and addresses, which is what makes it worth having.\n\n`list_unfiled_places` returns the saved places filed in **no** Guide. Maps shows them only in a\nunion view, and 7 of the 12 on the probed Mac appeared nowhere else in the store — not as a\nfavourite, not in another Guide, not in recents.\n\nIt reads **what is saved on this Mac**. It does not search Apple's map of the world, geocode an\naddress, or give directions; the guide says so, because answering those from general knowledge is\nthe most likely way for this surface to be wrong.\n\n**It writes, and it is the only surface here that does so without an Apple Event.** Maps ships no\nscripting dictionary and registers no App Intents on macOS, so `add_favorite` and `remove_favorite`\ngo into the Core Data store directly — which means `usesAppleEvents` stays false even with the write\ngate open, and adding this surface still widens no Automation consent.\n\nThe part that cannot be faked is the GEO place record, so it never is: the place is opened through\nthe `maps://` URL scheme, **Maps mints the record itself**, and it is copied. Two consequences the\ntools state rather than hide — saving a place the store does not already know leaves an entry in\nRecents, and because the store is mirrored by `NSPersistentCloudKitContainer` the write reaches every\ndevice on the account. There is no local-only insert here.\n\nIt was also declared impossible three times before it was found: the store has no file extension,\nit sits in the one directory of Maps' container that Full Disk Access gates, and\n`group.com.apple.Maps` is a decoy that is `EPERM` rather than empty. See\n[docs/maps.md](docs/maps.md).\n\nAll names are prefixed `apple_mail_` / `apple_notes_` / `apple_reminders_` / `apple_calendar_` /\n`apple_contacts_` / `apple_messages_` / `apple_safari_` / `apple_maps_`.\n\n## Prompts and resources\n\nTools are what an agent _calls_. Two other things every server knows are the wrong shape for a tool,\nbecause a tool result is not addressable and does not outlive the session that paid for it: the\ninventory every other tool takes as an argument, and the diagnostics that get read one round trip\nafter the confusing answer instead of before it.\n\nSo each server also exposes **resources**:\n\n| URI                                 | What                                                               |\n| ----------------------------------- | ------------------------------------------------------------------ |\n| `cupertino://<surface>/guide`       | the operating manual — static, so it reads with every grant denied |\n| `cupertino://<surface>/diagnostics` | the live capability and permission report                          |\n| `cupertino://<surface>/inventory`   | accounts and mailboxes / folders / lists / calendars               |\n\nand **workflow prompts**, which hold the constraints a tool description cannot: not \"what this call\ndoes\" but what order the calls go in. `apple_mail_triage`, `apple_mail_find_thread`,\n`apple_reminders_whats_due`, `apple_calendar_whats_my_day`, `apple_messages_catch_up` and the rest,\none to three per surface. Each embeds its surface guide, so a host that expands a prompt hands the\nmodel the reference material with it.\n\nWrite prompts follow the write tools exactly: with writes off, `apple_mail_draft_reply` is not\nrefused, it is **not registered**. Full design notes, and what was deliberately left out, in\n[docs/prompts-and-resources.md](docs/prompts-and-resources.md).\n\n## Configuration\n\nEnvironment only — these servers hold no secret of their own, so there is no config file. Prefix\nis `APPLE_MAIL_`, `APPLE_NOTES_`, `APPLE_REMINDERS_`, `APPLE_CALENDAR_`, `APPLE_CONTACTS_`,\n`APPLE_MESSAGES_`, `APPLE_SAFARI_` or `APPLE_MAPS_`.\n\n| Variable                 | Default       | What                                                           |\n| ------------------------ | ------------- | -------------------------------------------------------------- |\n| `*_ALLOW_WRITES`         | `false`       | register the mutating tools at all                             |\n| `*_EXPOSE_PROMPTS`       | `true`        | register the workflow prompts and `cupertino://` resources     |\n| `*_ACCOUNTS`             | all           | account allowlist (names or UUIDs) — the **read**-side control |\n| `*_ATTACHMENT_DIR`       | `~/Downloads` | the only directory attachments may be written into             |\n| `*_MAX_RESULTS`          | `200`         | cap on any listing                                             |\n| `*_INDEX_MODE`           | `auto`        | `auto` \\| `ro` \\| `immutable` \\| `off`                         |\n| `*_OSASCRIPT_TIMEOUT_MS` | `30000`       | per-Apple-Events-call timeout                                  |\n| `*_DEBUG`                | `false`       | verbose logging to stderr                                      |\n\n`allowWrites` gates mutation, but on Mail the larger blast radius is _reading_ an entire archive —\nthat is what `*_ACCOUNTS` is for, and it is enforced in exactly one place so no query path escapes\nit. Mail also takes `*_ROOT`, `*_ENVELOPE_INDEX`, `*_DEGRADED_MAX_MESSAGES`, `*_BODY_MAX_BYTES`,\n`*_BODY_SCAN_MAX`, `*_BODY_SCAN_BYTES` and `*_MAILBOX_CACHE_TTL_MS`; see [`packages/mail/src/config.ts`](packages/mail/src/config.ts).\n\n`*_EXPOSE_PROMPTS` is a cost knob, not a safety gate — which is why it defaults **on** while\nwrites default off. Measured across all seven servers with writes enabled, the prompt and resource\nlistings come to ~3.4k tokens against ~18.5k for the tool definitions, so about 18% on top of a\nbill that tools dominate either way; resource _contents_ cost nothing until something reads one. If\ncontext is the problem, running fewer servers is the far bigger lever.\n\nCalendar takes `APPLE_CALENDAR_WORKDAY_START`, `APPLE_CALENDAR_WORKDAY_END` and\n`APPLE_CALENDAR_WORKDAYS` (`mon,tue,wed,thu,fri`), which set the working day\n`apple_calendar_find_availability` offers time inside when a call names no hours of its own.\n\n## The app\n\nThe menu bar is Cupertino's whole surface — there is no Dock icon and no main window.\n\n| Section              | What it answers                                                                                                                                                                                                                                                         |\n| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Full Disk Access     | granted or not, with the button that opens the right Settings pane                                                                                                                                                                                                      |\n| One pane per surface | whether the surface is on at all, Automation status per app, the consent prompt, and the writes toggle                                                                                                                                                                  |\n| Connections          | which client is talking to which server right now, and how many tools it has called                                                                                                                                                                                     |\n| MCP clients          | a pane per client: what would be written, what is under those keys now, and the servers in that file Cupertino did not write. One-click wiring for all seven, `~/.codex/config.toml` spliced in place rather than re-serialised. See [docs/clients.md](docs/clients.md) |\n| Activity…            | opens a window listing every tool call, live                                                                                                                                                                                                                            |\n\nThe **Activity** window records tool names and the arguments each was called with. Message\ncontents — a mail body, a message, a note's text — are blanked unless you turn them on for that\nsurface, and results are recorded only for a surface that asks. Nothing is written to disk unless\nyou ask for it: by default the log is a bounded ring in memory, cleared when Cupertino quits.\n\nSettings › Activity turns on a durable **audit log**: append-only JSONL under Application Support,\n0600, in segments, with retention by age and size. Whether that file carries arguments is a second\nswitch, and whether it carries message contents is a third — getting a mail body onto disk takes\nthree deliberate acts, because that is what it is.\n\nEach record carries a hash of the one before it, so an edited field, a removed record or a truncated\nfile can be detected. That is the whole claim: it catches tampering by something that does not know\nit is a chain. It is **not** proof against anyone who can write the file, because they can recompute\nit. Export writes the segments plus a manifest; signing is optional, proves the export came from\nthis Mac unaltered, and means nothing to a recipient who was not given the key some other way.\nIt is the answer to \"what did the assistant just do with my mail?\", and the reason the servers run\nunder an app you can see rather than inside whichever editor spawned them.\n\nWrites are off per surface until you turn them on, and the toggle decides whether the mutating\ntools are registered at all — an assistant with writes off cannot see that they exist.\n\nSurfaces themselves can be switched off, from the switch in a surface's own pane or by\nright-clicking its row in the sidebar. A surface that is off is not served at all: its server key\nis left out of the clients Cupertino configures and pruned from the ones it has already written,\nits running servers are stopped, and the bridge refuses the connection if an older config still\nasks for it. That is the lever for the tool definitions you never use — eight servers wired\neverywhere is a cost every session pays. Clients configured before the change keep the entry until\nyou press Update in that client's own pane, which names the surfaces it still holds — and the dot\nbeside it in the sidebar turns amber until you do. The same button prunes the entry from every\nclient, including the TOML one.\n\nEverything that is true of one surface lives in that surface's pane: whether it is on, its\nAutomation grant, its writes toggle, its store, and what its server actually exposes. Everything\ntrue of one client lives in that client's pane, the same way — the file, the entries, and what else\nis in it. Settings keeps only what belongs to neither: Full Disk Access, Accessibility and System\nEvents, the audit log, updates and the licence.\n\n## Why a single app\n\nFull Disk Access is one indivisible whole-disk grant. Granting it per surface buys no containment\nand costs a System Settings trip each time, so every surface ships inside one signed, notarized app\ncalled Cupertino. [docs/distribution.md](docs/distribution.md) also records why the Mac App Store\ncannot host any of this, so the question does not get re-opened.\n\nBut the grant is not the only thing the app holds, and it is not the only reason to run the servers\nunder it rather than under an editor:\n\n|                                        |                                                                                                        |\n| -------------------------------------- | ------------------------------------------------------------------------------------------------------ |\n| **The grant lands on Cupertino**       | not on whichever editor spawned the server, and with it every extension and task that editor runs      |\n| **A visible audit trail**              | the Activity window lists every tool call, live; a server inside an editor is unobservable             |\n| **Writes are off, per surface**        | and the toggle decides whether the mutating tools are registered at all                                |\n| **`*_ACCOUNTS` bounds reading**        | the blast radius on Mail is the archive, not the mutations                                             |\n| **Results say how much to trust them** | `indexAgeSeconds`, a WAL-blind warning, and a structured `degraded` result rather than a vanished tool |\n| **Four surfaces, one grant**           | which is the actual payoff of the indivisibility above                                                 |\n\n[docs/alternatives.md](docs/alternatives.md) is the honest version of that list: what else reads\nApple Mail for an assistant, and where those tools are ahead.\n\n## Documentation\n\n|                                                                |                                               |\n| -------------------------------------------------------------- | --------------------------------------------- |\n| [docs/distribution.md](docs/distribution.md)                   | how this ships, and why not the App Store     |\n| [docs/surfaces.md](docs/surfaces.md)                           | which surfaces, and what each one costs       |\n| [docs/licensing.md](docs/licensing.md)                         | what is open, what is sold, what buys trust   |\n| [docs/alternatives.md](docs/alternatives.md)                   | what else reads Apple Mail, and where we lose |\n| [docs/mail-body.md](docs/mail-body.md)                         | the body-search lane, and how it is decided   |\n| [docs/mail-query.md](docs/mail-query.md)                       | the query lane, and why not CodeMode          |\n| [docs/notes.md](docs/notes.md)                                 | Apple Notes phase-0 measurements              |\n| [docs/reminders.md](docs/reminders.md)                         | Apple Reminders phase-0 measurements          |\n| [docs/messages.md](docs/messages.md)                           | Apple Messages: measurements, decoder, send   |\n| [docs/calendar.md](docs/calendar.md)                           | Apple Calendar phase-0 measurements           |\n| [docs/safari.md](docs/safari.md)                               | Safari phase-0 measurements                   |\n| [docs/maps.md](docs/maps.md)                                   | Maps phase-0 measurements                     |\n| [docs/envelope-index.md](docs/envelope-index.md)               | Mail's observed `Envelope Index` schema       |\n| [docs/prompts-and-resources.md](docs/prompts-and-resources.md) | what the servers expose beyond tools          |\n| [docs/verify.md](docs/verify.md)                               | checking the Mail server against a real index |\n\n## Working on it\n\n```bash\npnpm build          # every package\npnpm test           # every package\npnpm typecheck\npnpm lint\npnpm format\n```\n\nThe marketing site is its own workspace, and deploys by hand:\n\n```bash\npnpm --filter @mgcrea/cupertino-website dev     # astro dev\npnpm --filter @mgcrea/cupertino-website build   # static build\n```\n\nIt is built from the design canvas in `.idea/design/`, and reads its tool counts from\n`packages/*/src/tools/` rather than from this file — see [apps/website](apps/website/AGENTS.md).\n\nThe Swift half is `xcodebuild`, named by the Makefile rather than wrapped by it:\n\n```bash\nmake app            # build Cupertino.app (Debug)\nmake run            # build, point at packages/*/dist, launch\nmake smoke          # handshake every server through the bridge\nmake stop           # quit and remove the socket\n```\n\nThe app's screenshots are captured rather than taken by hand — `apps/apple/Screenshots/` holds the\nconfig and the committed goldens, and the website renders the output:\n\n```bash\nmake screenshots          # capture, gate against the goldens, compose both sets\nmake screenshots-check     # gate only — fails if the UI drifted\nmake screenshots-update    # accept the captures as the new goldens (review the diffs first)\nmake screenshots-selftest  # prove the gate fails when it should\n```\n\nA run takes over the pointer and the active app at the moment of each shot, so do not use the\nmachine while it runs. It needs Screen Recording permission for the **terminal**, never for\nCupertino itself. `make screenshots-doctor` checks that and the two other things that otherwise\nfail silently: whether the caption font resolves, and whether the output size is one a store would\naccept.\n\nWhat the screenshots show is fixture data from `apps/apple/Cupertino/DemoSeed.swift`, not this Mac:\nin `-ScreenshotMode` the app starts no host, seeds its own log and sessions, and answers the\npermission and store questions from a table. Without that the images would report one laptop's TCC\nstate and print its home directory into the marketing site.\n\nPhase-0 probes are repo-wide and read-only. They need the permission of the surface they measure,\nand redact their output to counts, timings and DDL:\n\n```bash\npnpm probe:mail      # Envelope Index — needs Full Disk Access\npnpm probe:mail-body # which lane can search message bodies — needs Full Disk Access\npnpm probe:notes     # Notes — the Apple Events half runs without it\npnpm probe:reminders # Reminders — the store path is a glob, so finding it is itself privileged\npnpm probe:messages  # chat.db — no Apple Events read lane exists, so this one needs the grant\n                     #   --send-target=<handle> also checks the send lane's targeting, without sending\npnpm probe:calendar  # settles whether Calendar has a file lane at all\npnpm probe:safari    # History.db, and the Reading List hiding inside Bookmarks.plist\npnpm probe:maps      # MapsSync, the store with no file extension behind the grant\npnpm probe:contacts  # the resolver Messages needs — its own TCC grant, not Full Disk Access\npnpm probe:screen    # ScreenCaptureKit — Screen Recording, and it takes effect on relaunch\n```\n\nEvery probed surface now has a package except `screen`, which the app serves in-process. **Safari's write set is the widest here, and it splits across two lanes that share nothing but the flag**: `open_url` and `add_reading_list_item` are Apple Events that move a real, visible browser, while `click`, `fill` and `scroll` act inside a page through the bundled Safari extension, which Safari consents to one website at a time — so their real gate is a per-site grant you can see and revoke, and the write flag is the second lock rather than the only one. `find_codes` sits behind its own `APPLE_SAFARI_ALLOW_CODES` rather than the write gate, because reading a 2FA code is a read; and there is deliberately no `do JavaScript` tool, because it needs a developer-menu toggle whose state cannot be read, so diagnostics could never say in advance whether it would work. See [docs/safari.md](docs/safari.md). **Maps writes to a store without an Apple Event at all**, which no other surface does: it has no scripting dictionary, so `add_favorite` asks Maps to mint a place record through the `maps://` URL scheme and then writes SQL into the Core Data store. That store is CloudKit-mirrored, so the write reaches every device on the account — the only write in the bundle whose blast radius exceeds the machine. [docs/maps.md](docs/maps.md) carries the four lanes that were measured to get there. Messages registers exactly one write tool, `send_message`, which is the whole of what its scripting dictionary can do.\nEvery probe degrades rather than exits — an app that is not running, or a permission that is not\ngranted, is reported as a finding — and none of them launches an app unless you pass `--launch`.\nTheir shared mechanism lives in [scripts/lib/probe-kit.mjs](scripts/lib/probe-kit.mjs).\n\n<!-- <generated:version> generated from package.json by `make version` — do not edit by hand -->\n\nReleases are tagged per package, so a tag names what it publishes: `mail-v1.14.0`,\n`reminders-v1.14.0`, `calendar-v1.14.0`, `core-v1.14.0`. The app is tagged `app-v1.14.0` and releases on its own lane —\na signed, notarized `Cupertino.zip` attached to the GitHub release, plus its SHA-256. See\n[docs/distribution.md](docs/distribution.md).\n<!-- </generated:version> -->\n\n> The repo is `cupertino`; the npm packages stay `@mgcrea/mcp-apple-*`, because that is what\n> people search npm for. Neither name is load-bearing. The bundle identifier `io.mgcrea.cupertino`\n> is the string that actually matters, because changing it would invalidate every user's Full Disk\n> Access grant.\n\n## Licence\n\nTwo, because the halves are not the same thing.\n\n| Part                        | Licence                                                                                       |\n| --------------------------- | --------------------------------------------------------------------------------------------- |\n| `packages/*`, `scripts/`    | [MIT](LICENSE) — libraries, vendor them freely                                                |\n| `apps/apple/`               | [source-available](apps/apple/LICENSE) — read, audit, compile; binary redistribution reserved |\n| the signed, notarized build | sold, under the [EULA](apps/apple/EULA) shipped with it                                       |\n\nCupertino asks for Full Disk Access, so the source stays readable — that is what such a grant is\nowed, and reading it is the point. Running it is a separate question: the licence check lives in the\nsource, so any build asks for a key, yours or ours. What is sold is the notarized binary and the\nmaintenance behind it. The servers are MIT and run on their own with no key at all. The reasoning is\nin [docs/licensing.md](docs/licensing.md).\n",
  "bytes": 43572,
  "sha": "c12612543f7a49c68ec81a7dba0b064bba324caae6996b674522a9022aee2f61",
  "repo_slug": "mgcrea/cupertino",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_mgcrea_mcp_apple_reminders_25eca87d/readme"
}