The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Apple Calendar listing page.
Put your agent to work in your everyday Apple apps.
MCP servers for the Apple apps already on your Mac, and the signed app that grants them their permissions once instead of once each — for any agent that speaks MCP, not for one host.
Unofficial. Not affiliated with Apple. These drive the apps that are already on your Mac.
Look at how I write in my work inbox, then draft this reply in the same voice.
Pull together everything about the Atlas launch from my mail, my notes and my calendar. What do I still owe people?
Turn the action items from yesterday's client thread into reminders, due Friday.
Every one of those carries a constraint — an account, a date bound, a filter. That is the part the
naive osascript path answers in 74 seconds or answers wrongly, and the reason a server earns its
place: it holds what the model would otherwise re-derive every session. The measurements are in
docs/verify.md; what the alternatives cost is in
docs/alternatives.md.
The last one needs the write gate open on Reminders. Writes are off per surface until you turn them on, and the toggle decides whether the mutating tools are registered at all — an agent with writes off cannot see that they exist.
| Surface | Package | Status |
|---|---|---|
packages/mail | implemented — 21 tools, search/read/attachments + gated writes | |
| Notes | packages/notes | implemented — 13 tools, search/read/attachments + gated writes |
| Reminders | packages/reminders | implemented — 11 tools, lists/search/dates + gated writes |
| Calendar | packages/calendar | implemented — 10 tools, ranges/search/free-time + gated writes |
| Contacts | packages/contacts | implemented — 7 tools, resolves handles to names + gated writes |
| Messages | packages/messages | implemented — 9 tools, chats/search/counts/decoded text + gated send and codes |
| Safari | packages/safari | implemented — 14 tools, history/tabs/reading list/page reads + gated writes and codes |
| Maps | packages/maps | implemented — 13 tools, favourites/Guides/recents + gated writes |
| Screen | — | implemented — 3 tools, ScreenCaptureKit; served in-app, no npm package; off until switched on |
| Sound | — | implemented — 10 tools, volume/routing/speech + gated recording; in-app; off until switched on |
| Desktop | — | implemented — 17 tools, AXUIElement natively; in-app, no npm package; off until switched on |
| Simulator | — | implemented — 10 tools, an iOS Simulator's screen in iOS points; in-app; off until switched on |
| — | packages/core | shared: the osascript boundary, TCC-aware errors, ro SQLite |
Screen, Sound, Desktop and Simulator arrive switched off. Every other surface that brokers an Apple app is on when Cupertino is installed; those four are not, because Screen Recording, the microphone and Accessibility are per-process grants that reach past the surface being brokered. Desktop reaches furthest — Accessibility does not scope to a target at all, so the right to press a button in Maps is the right to press one in anything, and it additionally arrives with writes off, so it can only look until you say otherwise. Simulator holds the same grant and pins its own reach to Simulator.app, with no switch that widens it. Switch them on in the surface list if you want them.
Each surface is its own server, so a host loads only the tools it wants. Every surface that brokers
an Apple app through its store or its scripting dictionary is also its own npm package; screen,
sound, desktop and simulator are not, and could not be — the first three broker a framework
rather than an app, simulator brokers an app through Accessibility, and in every case the grant
lives in the app, so the app serves them in-process and a published package could do nothing. See
docs/screen.md, docs/desktop.md and
docs/simulator.md.
simulator is the lane for an app you are building. Simulator.app bridges the simulated device's
accessibility tree into the Mac's, so an iOS app's own controls are readable and pressable here with
no WebDriverAgent and no runner process — and the surface answers in iOS points, the space
ios_simulator_tap and ios_simulator_screenshot use, with the window scale measured on every
call rather than assumed. It reads, finds and presses; behind writes it taps, swipes, types and
presses the device's buttons. Booting, installing, launching, screenshots and push need no grant
and stay with @mgcrea/mcp-ios-simulator, which
this complements rather than copies.
desktop is the one surface that drives an interface rather than reading a store, and it does it
through AXUIElement natively rather than through osascript. That distinction is the whole
surface: every Accessibility measurement this project took before 2026-09-05 went through System
Events, one Apple Event per attribute, which is where "33.6 ms a round trip" and "~14 s for a place
card" came from. Natively the same walks cost 1.24 ms a round trip and the same place card 0.177 s.
The transport was the cost, not the API — see docs/desktop.md.
They share one bundle and one Full Disk Access grant, which is the whole reason they live together — see docs/distribution.md.
Every server that brokers an Apple app is on npm and runs straight from npx — for Claude Code, a
.mcp.json beside your project:
The packages are MIT and need no licence key. What they do need is a permission, and on npm you
grant it to whatever launches them — your editor, your terminal — which is the trade the signed
Cupertino.app exists to avoid: one Full Disk Access grant held by a
notarized binary, instead of one per host. See docs/licensing.md.
Or run them from source:
then point your host at packages/<surface>/dist/cli.js by absolute path.
Writes are off unless you ask for them — see Configuration.
Running through the menu bar app instead routes every server through the bridge, so Full Disk Access is granted to Cupertino rather than to whichever editor spawned the server:
make surfaces writes a gitignored .mcp.json at the repo root, wired for that path — it is a
developer's local working config rather than something to commit, because every entry is an
absolute path into one Mac's bundle. make on its own lists every target.
Note the different server names. Wired by hand as above, a server is apple-mail and runs under
whatever grant its host process has. Wired by Cupertino it is cupertino-mail, because that entry
points at the app's bridge and runs under the app's grant. Two names for two deployments, and you
can have both. The app only ever touches its own cupertino-* keys — an apple-mail entry
belonging to some other server is left alone.
Cupertino is machine configuration, not a project dependency, so it belongs in a per-user config:
one file each, and the equivalent of --scope user everywhere. Deliberately not --scope project, which writes an .mcp.json meant to be committed — that entry is an absolute path into a
bundle on one Mac, backed by one person's Full Disk Access grant, and it would be useless to a
teammate and unwise to offer them.
All seven are written by the app, and nothing has to be pasted into a terminal. Six keep strict JSON
and are merged into as dictionaries — five under mcpServers, Visual Studio Code under servers in
User/mcp.json, which is a different file from the JSONC settings.json it was confused with for
two releases. The seventh, ~/.codex/config.toml, is TOML full of hand-written prose and structure,
so it is never re-serialised: ClientWiringTOML replaces the lines that hold MCP servers and quotes
every other byte verbatim. ChatGPT has no row of its own because it is not a separate client — the
ChatGPT app, the Codex CLI and the Codex IDE extension all read that same file, which is the row
called "ChatGPT & Codex".
Claude Code's ~/.claude.json is written directly, and it is the one config where that deserves a
paragraph: it holds this machine's credentials beside ninety-odd project blocks, and Claude Code
writes to it while it runs. So every write copies the file to ~/.claude.json.cupertino-backup
first, lands through a temp file and an atomic swap, and is refused outright if anything touched the
file between the read and the swap — in which case the app re-reads and merges again. What no
writer can promise is the last word: a session holding its own copy of the file will win, and
claude mcp add is the same read-modify-write from another process, which is why handing over that
line was never the safer option. The difference is that the app reads the file it wrote, so a
clobbered entry shows up as Not configured the next time you open Settings, with a button that
fixes it.
This repo's own gitignored .mcp.json is the exception, and it names its servers
cupertino-*-dev on purpose: it points at apps/apple/.build, so working on the app means having
the development build and the installed one side by side. Claude Code reports servers of the same
name in two scopes as a conflict rather than picking one, so the suffix is what keeps both usable.
Cupertino.app needs macOS 26 or later — its icon is an Icon Composer bundle, which nothing
older can render. The servers themselves are plain Node and carry no such floor; only the menu bar
app does.
These land on whatever process launched the server — your editor, your terminal, or Cupertino — never on Mail, Notes or Reminders themselves.
| Grant | Needed for |
|---|---|
| Full Disk Access | the index lane: Mail search, attachment bytes |
| Automation (per target app, prompted) | the Apple Events lane: accounts, mailboxes, all writes |
| Contacts (prompted) | the Contacts surface — its store is not behind Full Disk Access |
| Screen Recording (prompted) | the Screen surface |
| Microphone (prompted) | Sound's recording tools only |
| Accessibility (prompted) | the Desktop and Simulator surfaces — see the note below on reach |
The last three are why Screen, Sound, Desktop and Simulator arrive switched off: each is a per-process grant that reaches past the surface being brokered, and Accessibility does not scope to a target app at all — Simulator pins its own reach in the server, since the system will not.
System Settings → Privacy & Security → Full Disk Access → add the launching app, then restart it. Granting it to Mail.app does nothing; the reader needs the permission, not Mail.
What works without Full Disk Access:
| Surface | Without the grant |
|---|---|
| accounts, mailboxes and writes only — search falls back to Apple Events at ~74 s | |
| Notes | fully usable below roughly 5k notes; only attachment bytes need the grant |
| Reminders | usable, but all-day dates and subtasks need the store — the container cannot even be listed without it |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
Tools that need the index don't disappear when it's missing — the tool list is a pure function of
allowWrites and nothing else, because MCP clients cache it. They return a structured degraded
result naming what's absent. apple_mail_diagnostics / apple_notes_diagnostics report which lane
is live and how to grant what's missing.
Read tools are always registered. Write tools are invisible unless writes are enabled — not merely refused.
| Always available | Write-gated |
|---|---|
search_messages list_messages count_messages get_thread query | set_message_flags move_messages delete_messages check_for_new_mail |
get_message get_message_source list_attachments | send_message reply_to_message forward_message update_draft |
list_accounts list_mailboxes diagnostics | save_attachment create_mailbox |
| Always available | Write-gated |
|---|---|
list_notes search_notes get_note list_attachments | create_note update_note move_note delete_notes save_attachment |
list_accounts list_folders diagnostics | add_attachment |
| Always available | Write-gated |
|---|---|
list_reminders search_reminders get_reminder list_lists | create_reminder update_reminder complete_reminders move_reminders delete_reminders |
list_accounts diagnostics |
| Always available | Write-gated |
|---|---|
list_events search_events get_event list_calendars | create_event update_event delete_events |
find_availability list_accounts diagnostics |
list_events expands repeating events, so a weekly standup is returned once per week. Every result
carries the window the expansion is known to cover, and sets truncated when a range runs past it
rather than coming back short — a short list of events is indistinguishable from a free afternoon.
| Always available | Write-gated |
|---|---|
resolve_handles search_contacts list_contacts get_contact | create_contact update_contact |
diagnostics |
resolve_handles turns phone numbers and email addresses into names, which is what makes the
Messages surface readable at all. Read status on each result rather than assuming a name came
back: unknown is common and not an error, and ambiguous means two contacts share the number, so
no name is returned rather than a guess.
There is no delete tool. Contacts' scripting dictionary has no delete command of any kind, and this surface's writes go through Apple Events because its store is read-only by policy. That was true of every surface until Maps, which has no scripting dictionary to go through and writes SQL instead. See docs/contacts.md.
| Always available | Write-gated |
|---|---|
list_chats list_messages search_messages count_messages | send_message |
get_message save_attachment diagnostics |
find_codes is registered too, behind its own APPLE_MESSAGES_ALLOW_CODES rather than the write
gate — reading a one-time code is a read, and it is separated because it is the one read that hands
over a credential.
One write tool, because the dictionary has one usable command. sdef lists send, login and
logout; the other two would sign the user out of iMessage on every device they own. There is no
edit, delete, mark-as-read or reaction verb to expose.
This is the only surface where Apple Events is a write lane and nothing else — every read
through it fails, so with APPLE_MESSAGES_ALLOW_WRITES off no Apple Event is ever sent and no
Automation grant is ever requested.
send_message prefers a chatRef from list_chats over a raw handle, because Messages refuses to
enumerate participants for a script: an existing conversation is the only target that can be
addressed reliably. Apple Events hands back no identifier for what it sent, so the result is
reconciled against chat.db — reconciliation: "matched" carries a real message ref, and
"pending" means Messages accepted the send but has not written the row yet. Pending is not a
failure, and retrying it sends the message twice.
About 3% of messages across all history keep their text only in an archived NSArchiver blob that
SQL cannot reach — and Apple stopped writing the plain column in early 2026, so for anything recent
it is ~100%. This server decodes them; textSource on every result says which lane answered. See
docs/messages.md.
| Always available | Write-gated |
|---|---|
search_history get_page read_page list_tabs | open_url add_reading_list_item |
list_bookmarks list_reading_list page_elements | click fill scroll |
diagnostics |
The widest write set 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.
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 the user can see and
revoke, and the write flag is the second lock rather than the only one. find_codes is registered
separately behind APPLE_SAFARI_ALLOW_CODES, because reading a one-time code is a read.
There is deliberately no do JavaScript tool: it needs a developer-menu toggle that is not a TCC
grant and whose state cannot be read, so diagnostics could never say in advance whether it would
work.
list_tabs needs no Full Disk Access — it needs an Automation grant instead, and Safari has to be
running. Ask for the tab marked frontmost to get
the one the user is looking at: active means selected in its own window, so two open windows
produce two active tabs. A tab's history field being null means not found in history, never
"never visited": the match rate is a property of the tab set rather than of the surface, measured at
60.7%, 55.3% and 8.3% on three real runs. Single-page apps are the main reason — a page reached by
pushState commits no history row at all. Each match reports historyMatch, and even an exact one
can be a different site when the address is reused, as any localhost URL is.
Page content comes from a Safari extension, and only for websites you allow it on.
apple_safari_read_page returns a page as readable text or raw HTML. It is a snapshot taken when
the page loaded rather than a live read, so every result says when it was captured and how old it
is — a page you have navigated away from still answers, and saying so is the point.
There is still no do JavaScript tool. That verb needs "Allow JavaScript from Apple Events", a
developer-menu toggle which is not a TCC grant and whose own state cannot be read, so diagnostics
could never tell you in advance whether it would work — and it is global: any process able to send
Apple Events could then run script in any tab. Safari exposes no AXWebArea for page content
either, so the Accessibility lane that reaches Mail's composer does not reach a web page. The
extension is the only route, and it is the one Safari scopes per website.
See docs/safari.md.
| Always available | Write-gated |
|---|---|
list_favorites list_collections list_collection_places | add_favorite remove_favorite |
list_unfiled_places list_recents search_places get_place | save_place remove_saved_place |
diagnostics | add_place_to_guide |
Favourites, collections (Guides) and recents, from a Core Data store under Full Disk Access — with real coordinates and addresses, which is what makes it worth having.
list_unfiled_places returns the saved places filed in no Guide. Maps shows them only in a
union view, and 7 of the 12 on the probed Mac appeared nowhere else in the store — not as a
favourite, not in another Guide, not in recents.
It reads what is saved on this Mac. It does not search Apple's map of the world, geocode an address, or give directions; the guide says so, because answering those from general knowledge is the most likely way for this surface to be wrong.
It writes, and it is the only surface here that does so without an Apple Event. Maps ships no
scripting dictionary and registers no App Intents on macOS, so add_favorite and remove_favorite
go into the Core Data store directly — which means usesAppleEvents stays false even with the write
gate open, and adding this surface still widens no Automation consent.
The part that cannot be faked is the GEO place record, so it never is: the place is opened through
the maps:// URL scheme, Maps mints the record itself, and it is copied. Two consequences the
tools state rather than hide — saving a place the store does not already know leaves an entry in
Recents, and because the store is mirrored by NSPersistentCloudKitContainer the write reaches every
device on the account. There is no local-only insert here.
It was also declared impossible three times before it was found: the store has no file extension,
it sits in the one directory of Maps' container that Full Disk Access gates, and
group.com.apple.Maps is a decoy that is EPERM rather than empty. See
docs/maps.md.
All names are prefixed apple_mail_ / apple_notes_ / apple_reminders_ / apple_calendar_ /
apple_contacts_ / apple_messages_ / apple_safari_ / apple_maps_ — and apple_screen_ /
apple_sound_ / apple_desktop_ / apple_simulator_ for the surfaces the app serves itself.
Tools are what an agent calls. Two other things every server knows are the wrong shape for a tool, because a tool result is not addressable and does not outlive the session that paid for it: the inventory every other tool takes as an argument, and the diagnostics that get read one round trip after the confusing answer instead of before it.
So each server also exposes resources:
| URI | What |
|---|---|
cupertino://<surface>/guide | the operating manual — reads with every grant denied |
cupertino://<surface>/diagnostics | the live capability and permission report |
cupertino://<surface>/inventory | accounts and mailboxes / folders / lists / calendars |
and workflow prompts, which hold the constraints a tool description cannot: not "what this call
does" but what order the calls go in. apple_mail_triage, apple_mail_find_thread,
apple_reminders_whats_due, apple_calendar_whats_my_day, apple_messages_catch_up and the rest,
one to three per surface. Each embeds its surface guide, so a host that expands a prompt hands the
model the reference material with it.
Write prompts follow the write tools exactly: with writes off, apple_mail_draft_reply is not
refused, it is not registered. Full design notes, and what was deliberately left out, in
docs/prompts-and-resources.md.
Environment only — these servers hold no secret of their own, so there is no config file. Prefix
is APPLE_MAIL_, APPLE_NOTES_, APPLE_REMINDERS_, APPLE_CALENDAR_, APPLE_CONTACTS_,
APPLE_MESSAGES_, APPLE_SAFARI_ or APPLE_MAPS_.
| Variable | Default | What |
|---|---|---|
*_ALLOW_WRITES | false | register the mutating tools at all |
*_EXPOSE_PROMPTS | true | register the workflow prompts and cupertino:// resources |
*_LAZY_TOOLS | false | serve a search tool and a dispatcher instead of the tool list |
*_ACCOUNTS | all | account allowlist (names or UUIDs) — the read-side control |
*_ATTACHMENT_DIR | ~/Downloads | the only directory attachments may be written into |
*_MAX_RESULTS | 200 | cap on any listing |
*_INDEX_MODE | auto | auto | ro | immutable | off |
*_OSASCRIPT_TIMEOUT_MS | 30000 | per-Apple-Events-call timeout |
*_DEBUG | false | verbose logging to stderr |
allowWrites gates mutation, but on Mail the larger blast radius is reading an entire archive —
that is what *_ACCOUNTS is for, and it is enforced in exactly one place so no query path escapes
it. Mail also takes *_ROOT, *_ENVELOPE_INDEX, *_DEGRADED_MAX_MESSAGES, *_BODY_MAX_BYTES,
*_BODY_SCAN_MAX, *_BODY_SCAN_BYTES and *_MAILBOX_CACHE_TTL_MS; see packages/mail/src/config.ts.
*_EXPOSE_PROMPTS is a cost knob, not a safety gate — which is why it defaults on while
writes default off. Measured across all eight node servers with writes enabled, the prompt and
resource listings come to ~3.7k tokens against ~25.3k for the tool definitions, so about 14% on top
of a bill that tools dominate either way; resource contents cost nothing until something reads
one. The per-surface breakdown is in
docs/prompts-and-resources.md.
*_LAZY_TOOLS is the knob for that ~25.3k. With it on, a server lists its diagnostics tool plus
apple_<surface>_search_tools, _describe_tool and _call_tool — and _call_write_tool when
writes are on — instead of every tool up front: ~5.8k tokens across the eight servers rather than
~25.3k, a 4.4× cut. It is off by default because it trades rather than tightens. The server
refuses exactly what it refused before, and a write tool is still absent rather than merely hidden
when *_ALLOW_WRITES is off, but your client's per-tool permission prompt collapses into two: one
for this surface's reads, one for its writes. Leave it off for Claude Code and Claude Desktop,
which already fetch tool schemas only when a task needs them and so pay the cost for nothing.
Calendar takes APPLE_CALENDAR_WORKDAY_START, APPLE_CALENDAR_WORKDAY_END and
APPLE_CALENDAR_WORKDAYS (mon,tue,wed,thu,fri), which set the working day
apple_calendar_find_availability offers time inside when a call names no hours of its own.
The menu bar is Cupertino's whole surface — there is no Dock icon and no main window.
| Section | What it answers |
|---|---|
| Full Disk Access | granted or not, with the button that opens the right Settings pane |
| One pane per surface | whether the surface is on at all, Automation status per app, the consent prompt, and the writes toggle |
| Connections | which client is talking to which server right now, and how many tools it has called |
| 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 |
| Activity… | opens a window listing every tool call, live |
The Activity window records tool names and the arguments each was called with. Message contents — a mail body, a message, a note's text — are blanked unless you turn them on for that surface, and results are recorded only for a surface that asks. Nothing is written to disk unless you ask for it: by default the log is a bounded ring in memory, cleared when Cupertino quits.
Settings › Activity turns on a durable audit log: append-only JSONL under Application Support, 0600, in segments, with retention by age and size. Whether that file carries arguments is a second switch, and whether it carries message contents is a third — getting a mail body onto disk takes three deliberate acts, because that is what it is.
Each record carries a hash of the one before it, so an edited field, a removed record or a truncated file can be detected. That is the whole claim: it catches tampering by something that does not know it is a chain. It is not proof against anyone who can write the file, because they can recompute it. Export writes the segments plus a manifest; signing is optional, proves the export came from this Mac unaltered, and means nothing to a recipient who was not given the key some other way. It is the answer to "what did the assistant just do with my mail?", and the reason the servers run under an app you can see rather than inside whichever editor spawned them.
Writes are off per surface until you turn them on, and the toggle decides whether the mutating tools are registered at all — an assistant with writes off cannot see that they exist.
Surfaces themselves can be switched off, from the switch in a surface's own pane or by right-clicking its row in the sidebar. A surface that is off is not served at all: its server key is left out of the clients Cupertino configures and pruned from the ones it has already written, its running servers are stopped, and the bridge refuses the connection if an older config still asks for it. That is the lever for the tool definitions you never use — eight servers wired everywhere is a cost every session pays. Clients configured before the change keep the entry until you press Update in that client's own pane, which names the surfaces it still holds — and the dot beside it in the sidebar turns amber until you do. The same button prunes the entry from every client, including the TOML one.
Everything that is true of one surface lives in that surface's pane: whether it is on, its Automation grant, its writes toggle, its store, and what its server actually exposes. Everything true of one client lives in that client's pane, the same way — the file, the entries, and what else is in it. Settings keeps only what belongs to neither: Full Disk Access, Accessibility and System Events, the audit log, updates and the licence.
Full Disk Access is one indivisible whole-disk grant. Granting it per surface buys no containment and costs a System Settings trip each time, so every surface ships inside one signed, notarized app called Cupertino. docs/distribution.md also records why the Mac App Store cannot host any of this, so the question does not get re-opened.
But the grant is not the only thing the app holds, and it is not the only reason to run the servers under it rather than under an editor:
| The grant lands on Cupertino | not on whichever editor spawned the server, and with it every extension and task that editor runs |
| A visible audit trail | the Activity window lists every tool call, live; a server inside an editor is unobservable |
| Writes are off, per surface | and the toggle decides whether the mutating tools are registered at all |
*_ACCOUNTS bounds reading | the blast radius on Mail is the archive, not the mutations |
| Results say how much to trust them | indexAgeSeconds, a WAL-blind warning, and a structured degraded result rather than a vanished tool |
| Seven app surfaces, one grant | which is the actual payoff of the indivisibility above; the other five name their own |
docs/alternatives.md is the honest version of that list: what else reads Apple Mail for an assistant, and where those tools are ahead.
| docs/distribution.md | how this ships, and why not the App Store |
| docs/surfaces.md | which surfaces, and what each one costs |
| docs/licensing.md | what is open, what is sold, what buys trust |
| docs/alternatives.md | what else reads Apple Mail, and where we lose |
| docs/mail-body.md | the body-search lane, and how it is decided |
| docs/mail-query.md | the query lane, and why not CodeMode |
| docs/notes.md | Apple Notes phase-0 measurements |
| docs/reminders.md | Apple Reminders phase-0 measurements |
| docs/messages.md | Apple Messages: measurements, decoder, send |
| docs/calendar.md | Apple Calendar phase-0 measurements |
| docs/safari.md | Safari phase-0 measurements |
| docs/maps.md | Maps phase-0 measurements |
| docs/simulator.md | the Simulator surface: measurements, geometry |
| docs/envelope-index.md | Mail's observed Envelope Index schema |
| docs/prompts-and-resources.md | what the servers expose beyond tools |
| docs/verify.md | checking the Mail server against a real index |
| docs/mail-compose.md | the composer lane, driven natively |
| docs/screen.md | the Screen surface, served in-process |
| docs/sound.md | the Sound surface, and its two gates |
| docs/desktop.md | driving any app's interface natively |
| docs/ax-lane.md | the rules a negative has to meet |
| docs/contacts.md | Apple Contacts phase-0 measurements |
| docs/clients.md | wiring the servers into each MCP host |
| docs/succession.md | what happens to this if I stop |
The marketing site is its own workspace, and deploys by hand:
It is built from the design canvas in .idea/design/, and reads its tool counts from
packages/*/src/tools/ rather than from this file — see apps/website.
The Swift half is xcodebuild, named by the Makefile rather than wrapped by it:
The app's screenshots are captured rather than taken by hand — apps/apple/Screenshots/ holds the
config and the committed goldens, and the website renders the output:
A run takes over the pointer and the active app at the moment of each shot, so do not use the
machine while it runs. It needs Screen Recording permission for the terminal, never for
Cupertino itself. make screenshots-doctor checks that and the two other things that otherwise
fail silently: whether the caption font resolves, and whether the output size is one a store would
accept.
What the screenshots show is fixture data from apps/apple/Cupertino/DemoSeed.swift, not this Mac:
in -ScreenshotMode the app starts no host, seeds its own log and sessions, and answers the
permission and store questions from a table. Without that the images would report one laptop's TCC
state and print its home directory into the marketing site.
Phase-0 probes are repo-wide and read-only. They need the permission of the surface they measure, and redact their output to counts, timings and DDL:
Every probed surface has a package except screen, sound and desktop, which the app serves in-process because their grants live in the app rather than in a package. 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. 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 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.
Every probe degrades rather than exits — an app that is not running, or a permission that is not
granted, is reported as a finding — and none of them launches an app unless you pass --launch.
Their shared mechanism lives in scripts/lib/probe-kit.mjs.
Releases are tagged per package, so a tag names what it publishes: mail-v1.20.0,
reminders-v1.20.0, calendar-v1.20.0, core-v1.20.0. The app is tagged app-v1.20.0 and releases on its own lane —
a signed, notarized Cupertino.zip attached to the GitHub release, plus its SHA-256. See
docs/distribution.md.
The repo is
cupertino; the npm packages stay@mgcrea/mcp-apple-*, because that is what people search npm for. Neither name is load-bearing. The bundle identifierio.mgcrea.cupertinois the string that actually matters, because changing it would invalidate every user's Full Disk Access grant.
Two, because the halves are not the same thing.
| Part | Licence |
|---|---|
packages/*, scripts/ | MIT — libraries, vendor them freely |
apps/apple/ | source-available — read, audit, compile; binary redistribution reserved |
| the signed, notarized build | sold, under the EULA shipped with it |
Cupertino asks for Full Disk Access, so the source stays readable — that is what such a grant is owed, and reading it is the point. Running it is a separate question: the licence check lives in the source, so any build asks for a key, yours or ours. What is sold is the notarized binary and the maintenance behind it. The servers are MIT and run on their own with no key at all. The reasoning is in docs/licensing.md.