pyapple-mcp
MCP server and CLI for seven macOS apps — Messages, Mail, Notes, Calendar, Reminders, Contacts and Maps. Native frameworks, honest truncation.
Documentation
PyApple MCP Tools
A Python implementation of Apple-native tools for the Model Context Protocol (MCP), providing seamless integration with macOS applications.
There are two front ends over the same integrations: an MCP server for Claude
Desktop and other MCP clients, and `pyapple`, a command line for driving the same
ten tools from a shell. They share one permission policy, so what you allow in
one you have allowed in both.
88 operations across ten tools. They are listed in full under
The tools, and what they refuse to do is under
Limits worth knowing — read that before you rely on any
of them.
If you are an agent about to drive the CLI rather than a person reading about it,
`.claude/skills/pyapple/SKILL.md` in this repository is the operator guide: which
call to make first, how to read the exit status, and which refusals to route
around versus report.
Features
- Messages: find conversations, read a thread whole, search message text,
list what a thread was sent, and send — text or a file. Reads go through
`chat.db` directly and are scoped by *chat*, so a group thread comes back as the
group rather than as one participant's share
- Notes: search, list, read, create, append, rename and rewrite — with a gate
that refuses any note whose content a round trip would destroy — plus
filing a note into another folder, and folders themselves: list them, create
one, rename one, delete one. `create` refuses a folder name that does not exist
rather than making a folder out of a typo, and the listing is how you find out
what does; `folder-delete` refuses a folder holding notes unless you pass their
exact count, because what it deletes does not go to Recently Deleted
- Contacts: search by name, go the other way from a number or address to a
person, and add, correct or delete a card. Reads and writes both go through the
Contacts framework; nothing launches Contacts.app
- Mail: unread, header search, body search, read one message whole by id,
a whole thread in order, what is attached and whether the bytes are
here, coverage — what each account can actually answer about — send, reply,
forward, the drafted form of each of those three, mark read, file, delete, and
list mailboxes and accounts. Reads come from the local Envelope Index rather
than through the app
- Reminders: list, search, create, update, complete, uncomplete and delete,
plus creating and deleting the lists themselves, through EventKit
- Calendar: list and search occurrences of recurring events, list calendars,
coverage — what range of events each calendar actually holds —
find free gaps rather than events, create with repeats, alarms and
invitees, reschedule, move and delete
- Maps: location search and directions through MapKit — no window, no
Automation grant, and Maps never launches. Favourites and Guides are not
available at all and say so
- Shortcuts: list the user's own automations, list the library's folders,
describe one, open it in the editor, run it. Through `/usr/bin/shortcuts` — a
subprocess and not an Apple Event, so it costs no Automation grant and
launches nothing
- Presence: whether now is a moment to interrupt — Focus mode, the frontmost
app, idle seconds, whether the screen is locked. Read-only, and structurally
so: there is no setter and there must not be one
- Doctor: why a tool just failed. Which macOS grants this process holds, per
tool, and where to click for the missing ones. Nothing it does can launch an app
or raise a permission prompt
- Command line: the same ten tools from a shell, with `--json` output that
says how much of the answer came back, and an exit status you can branch on —
see Command Line
- Permissions: one policy string, honoured identically by the server and the
CLI, and an optional two-phase gate that
makes any send show what it would send first — see
Quick Installation
Automated Setup (Recommended)
# Install pyapple-mcp
pip install pyapple-mcp
# Run the setup helper to configure Claude Desktop
pyapple-mcp-setupThe setup helper will:
- Find your pyapple-mcp installation
- Locate your Claude Desktop config file
- Automatically add the configuration
- Display helpful setup information
Manual Installation
1. Install pyapple-mcp:
pip install pyapple-mcp2. Configure Claude Desktop by editing `~/Library/Application Support/Claude/claude_desktop_config.json`:
{
"mcpServers": {
"pyapple": {
"command": "pyapple-mcp"
}
}
}3. Restart Claude Desktop to load the new configuration.
What gets installed
Three commands land on your `PATH`:
| Command | What it is |
|---|---|
| `pyapple` | the command line — the ten tools from a shell |
| `pyapple-mcp` | the MCP server, which is what an MCP client launches |
| `pyapple-mcp-setup` | the helper that writes the Claude Desktop config |
Nothing else is needed to use the CLI; `pip install pyapple-mcp` is the whole
installation.
pyapple --version
pyapple --helpUsage Examples
Through the MCP server you ask in plain language; through the CLI you type the
operation. The first two headings below are the former, the third is the latter.
Basic Commands
Can you send a message to my sister saying "Hello from Claude!"?Find all notes about "AI research" and summarize themCreate a reminder to "Buy groceries" for tomorrow at 5pmWhen am I free for an hour on Thursday?Get directions from "Apple Park" to "San Francisco Airport"Advanced Workflows
You can chain commands together for complex workflows:
"Find the email with the booking reference in it, and put the reference in my trip note"From a shell
The same operations are available without an MCP client at all:
pyapple messages conversations --query "book club"
pyapple mail unread --limit 5 --since 2026-08-01
pyapple calendar free --from-date 2026-08-06 --to-date 2026-08-08 --duration 60
pyapple presence snapshot
pyapple doctor reportSee Command Line below.
Limits worth knowing
Every one of these was measured against the real applications, and each is
something you need to know before relying on a tool rather than after. They
are not bugs waiting to be fixed; where a thing cannot be done, the operation
still exists and refuses out loud, because an assistant that has no operation for
what it was asked will reach for the nearest one that does exist.
Almost nothing launches an app any more — but Mail, Messages and Notes still do
Reminders, calendar and contacts writes moved from Apple Events to
EventKit and `CNSaveRequest`; maps search and directions moved to MapKit. Those
four tools now need only the privacy grant their reads already held, and the
separate *"control Automation"* grant they used to demand is gone. Verified with
Calendar, Reminders and Maps all quit: listing calendars, listing reminder lists,
finding free time, searching for a place and routing between two of them launched
none of the three.
What still dispatches Apple Events, and therefore still launches its app and
still needs Automation:
| Tool | What needs Automation |
|---|---|
| `notes` | everything, reads included — `move` and `delete` too |
| `mail` | `send`, `reply`, `draft`, `replyDraft`, `forward`, `forwardDraft`, `move`, `delete`, `open`, `markRead`, `mailboxes`, and `unread --mark-read`. `reply`, `replyDraft`, `forward`, `forwardDraft` and anything with `--html` need an Accessibility grant on top |
| `messages` | `send`, `sendAttachment`, `open` |
| `calendar` | `open`, and `create --invitees` |
| `reminders` | `open` |
| `contacts`, `maps`, `shortcuts`, `presence`, `doctor` | nothing |
`mail thread` and `mail attachments` are on the index-and-filesystem side with
the other reads: no Apple Event, Mail is not launched, and nothing is marked
read. `reminders listCreate` and `listDelete` are EventKit like the rest of that
tool.
`shortcuts` is a fourth route into macOS and the cheapest one here. It goes
through `/usr/bin/shortcuts`, a plain subprocess. Measured against the binary
Apple ships: `codesign -d --entitlements -` shows no
`com.apple.security.automation.apple-events`, and `otool -L` finds no AppleEvents
and no Carbon — it reaches its daemon over `mach-lookup`. `shortcuts list` and
| `shortcuts run ` comes back as `text`: the text survives, the URL does not | |
|---|---|
| headings | five input forms of `` all stored as a bold span |
| checklists | ten candidate markups all stored as plain ``, every tick cleared |
So `append`, `rename`, `replace` and `setBody` refuse any note carrying that
content, name what it holds, and change nothing. There is deliberately no
override flag: a flag saying "yes, lose the images" would be set by a model that
has never seen the note, on behalf of someone who has, and the loss is invisible
afterwards — the attachment does not go to the Trash, it is gone.
On the development machine 0 of 56 existing notes pass that gate — 40 carry
an attachment, 36 an image, 19 a heading, 13 a link, and 6 are password
protected. That is the honest number, and it is why the refusal is worth
documenting rather than discovering. Notes this tool creates always pass, so
`create` then `append` works as you would expect.
`create` and `delete` are unaffected by that gate: neither reads a body back.
`create` has a refusal of its own, though. **It used to make any folder it was
handed**, silently, so a misspelled folder name did not fail — it started a
second folder and filed the note there, reporting success. It now refuses a
folder that does not exist and names the ones that do. `notes folders` is the
listing that lets you check first, and `notes folder-create` is how you make one
on purpose; the tool's own default folder is the one exemption, since a caller
that named no folder cannot be told the folder it did not ask for is missing.
The refusal is the right way round because of what it costs to be wrong either
way. Refusing when a new folder was genuinely wanted costs one further call to
`folder-create`, immediately, with the error naming what the folder could have
been. Creating on a typo costs a folder in your sidebar, and two more calls —
`notes move` to file the note where it belonged and `folder-delete` to remove
what the typo made.
`notes folder-delete` destroys what is in it
Every other delete in this package is recoverable from a Trash or a Recently
Deleted. This one is not, and that is measured rather than assumed: a disposable
folder holding two notes was deleted, the trash's own note count was **3 before
and 3 after**, and a search of `every note` — which does reach the trash, since
the same query found a trashed note by name in the same run — matched 0 of
the two deleted bodies. The notes are gone, not filed away.
So the refusals are the feature, and there are four of them:
- A folder holding notes is refused unless you pass `--delete-notes N` with
the folder's exact count. `N` is re-checked against the live store, so a
count that has moved since you read it refuses rather than proceeds. You
cannot pass the flag without having counted first, and that interlock is the
only thing between a mistyped folder name and an unrecoverable loss.
- A folder with subfolders is refused outright, with no override at all.
Deleting a container leaves the app in a state where `id of` that container
raises, and there is no argument that gets past this.
- The trash and any account's default folder are refused for what they are.
There is one of each per account and nothing here needs to remove them.
- The result carries `may_return: True` rather than a bare success, because
a synced account may put the folder back. Measured on both account types on
one machine: an Exchange folder was listed again inside 15 seconds and was
still there five minutes later, and iCloud resurrection has been reproduced
five times — but neither reproduces every time, which is why the caveat is
unconditional rather than naming an account. The script re-queries after
deleting and reports what it saw, so what you get is what the store said, not
what the command intended.
The message says only what happened. Delete an empty folder and it warns
that a synced account may restore it and stops there; the sentence about notes
not being in Recently Deleted appears only when notes were actually
destroyed, and `notes_recoverable` is `null` rather than `false` when there
were none to recover.
pyapple notes folders # read the count first
pyapple notes folder-delete "Scratch" --delete-notes 2Four of seven maps operations are permanently impossible
Maps.app ships no scripting dictionary — there is no `.sdef` anywhere in it
— and Favourites and Guides have no public API and no readable on-disk copy; the
data is CloudKit-backed and opaque. So `save`, `listGuides`, `createGuide` and
`addToGuide` cannot be implemented at all.
They report failure and hand back a durable `maps.apple.com` link instead, which
is the part you can actually keep: stored in a note or a reminder it survives and
opens in Maps on every Apple device.
$ pyapple maps list-guides; echo $?
failed: Apple Maps guides cannot be listed. macOS exposes no API for them and keeps no readable copy on disk — the data lives in iCloud in a private format. This is not an empty list of guides; it is no answer at all. Open Maps and look under Library to see them.
1`listGuides` in particular must never answer with an empty list: a store that
cannot be read has to be distinguishable from a store with nothing in it.
There is also no current location. A non-bundled process cannot hold the
Location Services grant, so "directions from here" has no answer and
`directions` always needs an explicit `--from`.
A shortcut's actions cannot be read, which is why `run` is a `send`
`/usr/bin/shortcuts` has four subcommands — `run`, `list`, `view`, `sign` — and
Apple's own help for the third reads *"View a shortcut in Shortcuts."* It opens
the editor. There is no subcommand that describes what a shortcut does, so
`shortcuts describe` reports `actions: null` and `actions_known: false` and says
why, rather than returning an empty list. A shortcut that could not be described
must not read as a shortcut that does nothing.
(`~/Library/Shortcuts/Shortcuts.sqlite` carries a `ZACTIONSDESCRIPTION` column,
which is the obvious way round it. It was populated for 2 of 7 shortcuts on
the library this was written against, so it is a cache and not a source: reading
it would answer "what does this do" for some shortcuts and silently for none of
the others. It is not read.)
That is also the whole argument for `run` being classified `send` rather than
`write`. An effect nobody can read has to be assumed to be the worst one it could
have, and a shortcut can trivially send mail. The decisive part is what the
alternative would do to a promise this package already makes:
`PYAPPLE_READ_ONLY=1` is documented as `*=read` and it is the flagship safety
setting, and if `run` were anything below `send` that setting would permit
arbitrary user code that can text a stranger. Classified too high it costs
somebody an extra grant; classified too low it sends the email.
One more trap, closed rather than documented: `shortcuts list --folder-name`
**ignores a folder it does not recognise and returns the entire library at exit
0**. Measured — `-f "Starter Shortcuts"` gave 3, `-f none` gave 4, and
`-f "@@@nope@@@"` gave all 7, with nothing on stderr. So `pyapple shortcuts
list --folder` validates the name against the folder listing first and refuses
with the real names, at exit `2`. A mistyped filter that came back with
everything you own would be a wrong answer wearing a success.
Focus costs Full Disk Access, and its format is undocumented
Four of the five `presence` operations cost the user nothing at all — that is
measured rather than assumed, from an unsigned `.app` launched through
LaunchServices so that its responsible process was itself and it held no TCC
grant of any kind. That control was denied `chat.db` with `EPERM`, which is what
makes the rest of the row mean something: from it, `NSWorkspace.frontmostApplication()`,
`CGEventSourceSecondsSinceLastEventType` and `CGSessionCopyCurrentDictionary` all
answered.
`presence focus` is the exception. Focus state lives in
`~/Library/DoNotDisturb/DB`, which is behind Full Disk Access, and Apple
documents neither the directory nor the JSON in it. So every presence value
carries a `provenance` field, and Focus's says `reverse-engineered` while the
other three say `documented`. The result also reports the schema version it found
and whether that version is one this package has seen, so a macOS update that
changes the format shows up as an unrecognised schema rather than as a confident
wrong answer.
`INFocusStatusCenter`, the one public documented Focus API, was tried and
rejected: it returns only `isFocused` as an optional Bool and **never names the
mode**, and it is gated on an `NSFocusStatusUsageDescription` in an `Info.plist`,
which a `pip`-installed console script does not have. It would cost a new
dependency and an app bundle to learn strictly less.
`presence snapshot` is the one to poll and the only one that survives a partial
failure: every field carries its own `available`, so a field that could not be
read stays distinct from a field that is genuinely off, and the envelope carries
`unavailable` and `complete`. Without Full Disk Access a snapshot still answers
about the app, the idle clock and the screen.
There is no write half of this tool and there must not be one. Setting Focus
or moving a window needs an Accessibility grant — the broadest permission macOS
hands out, unscopeable, with effects nothing can verify afterwards because a
synthetic keystroke leaves no record distinguishable from a real one. Sensing
costs one grant a person can reason about; acting costs the machine. For the same
reason there is no clipboard read here: it is not an attention signal, and it is
the highest-value thing on the machine to exfiltrate.
`mail delete` refuses the accounts where it would not be recoverable
Mail's `delete` verb is what the Delete key is: on an ordinary account it files
the message in that account's Trash, which is a `write` — private, and undoable.
But that is a per-account setting, not a fact about Mail. `Mail.sdef`
declares `move deleted messages to trash` on the `account` class, and with it off
a delete *erases*. Nothing in the verb's name says so and the caller cannot see
the setting, so the script reads it off the message's own account before
touching anything and refuses when it is off, pointing at `move` instead. The
same refusal covers a message whose account could not be determined at all: an
unverifiable claim of recoverability is not a weaker claim, it is a false one.
On the path that does go ahead, "recoverable" is qualified rather than promised.
Two further account settings — `empty trash on quit` and `empty trash frequency`
(0 means on quit, -1 means never, anything else is days) — decide how long the
Trash keeps it, and both are read and both are reported. "It is in the Trash" is
a promise with an expiry.
`mail move` is the reversible neighbour and it refuses three things rather than
guessing: a destination that names no mailbox (with the real ones listed — it will
not create a mailbox out of a typo, which would sync a new folder to somebody's
phone), a name that matches more than one (`Sent` on a machine with three accounts
is three mailboxes), and a destination it could not *check*, since listing
mailboxes is itself an Apple Event and an empty answer there means "Mail did not
answer" rather than "there are none".
Whether an attachment is on this Mac is a `stat`, and the answer is tri-state
Both attachment operations report presence by looking for the file, not by
believing the database, and neither will report "not there" when it means "cannot
say".
`messages attachments` carries `available_locally`, and it has three values.
Measured across 5,426 rows on a real store: 35 of the 5,404 that name a path have
no file at that path, and two of those 35 carry the same `transfer_state` as the
5,365 that do — so the column cannot be trusted and presence is a `stat`. `None`
means no path was recorded at all, which is unknowable rather than false.
`mail attachments` reports names and sizes, and sizes are not in the index:
the `attachments` table has four columns and none of them is a size, so a size can
only come from the file. On the store measured, 2,755 of 8,101 indexed
attachments — 34.0% — have their bytes on this Mac, the same split `coverage`
reports for bodies. A size that is not known is `null`, never `0`. A
zero-byte attachment and one whose bytes were never downloaded are different
facts, `on_disk` says which you have, and the note says how many of each.
Every mail `send` leaves a copy in the sender's Drafts
Mail autosaves any outgoing message it can attribute to an account. Measured
directly, by building a message that was never saved and never sent: it appeared
in Drafts within 8 seconds and stayed.
This is known and not fixed. The copy appears asynchronously, so an
in-script cleanup runs too early, and a later one would mean this package
deleting from your Drafts by subject match. It is documented rather than papered
over because the failure it causes is a person seeing the draft, concluding the
mail never went, and sending it again.
`mail searchBodies` is partial by construction, and the split is per account
Bodies are not in the Envelope Index, so a body search has to open `.emlx` files
on disk — and those exist for only part of the store. On the development machine
that is 14,532 files against 45,654 indexed rows, about 32%.
That single number was the misleading way to say it, which is why `coverage`
exists. The three accounts on that machine are nothing like each other: one holds
31,143 indexed messages and zero bodies, and the other two are at 100% of
14,245 and of 279. So "32%" is the mean of a zero and two hundreds, and a body
search that finds nothing across the first account is not a failed search —
there was nothing there to search inside. Run `mail coverage` before you conclude
anything from an empty body search.
It is its own operation rather than a flag on `search` for exactly that reason: a
flag would hide the partiality behind a list that cannot say what it missed. The
result carries `candidates` (rows in scope), `scanned` (rows the budget reached),
`with_body`, `without_body` and `scan_truncated`, so a caller can see what was
not read rather than inferring it from a short list.
pyapple mail search-bodies "booking reference" --since 2026-07-01 --scan-limit 500`--scan-limit` is how many bodies to open, 500 by default. Decoding the 500
newest bodies is 2.25s and 2,000 is 5.98s, so it is a real budget and the default
is the one that answers in about two seconds. Raising it reads more of the store;
no setting makes a body appear that was never downloaded.
Always give it a `--since` or an `--account`. Unfiltered, the candidate set on
that machine is 31,192 and the newest of them come from the account with no
bodies at all, so the scan spends its whole budget on messages that cannot match.
A truncated scan says so — `scan INCOMPLETE` in the text, `scan_truncated` in
`--json` — and a truncated zero is not the same fact as a complete one.
`coverage` is the answer to "why did that find nothing"
Two operations exist only to be called *before* a read, because an empty result
and a nonexistent thing look identical otherwise.
`mail coverage` reports, per account: how many messages are indexed, how many
have a body on this Mac, that as a percentage, and the dates the index spans. It
reads the index and walks the account directories — no Apple Event, so **Mail is
not launched**, unlike `mail mailboxes`. `--mailboxes` asks the same question one
level down, per mailbox: a mailbox listed with `0 indexed` is empty, and a
mailbox *absent from the listing* is one this Mac has never synced. Both report
zero unread and they are not the same fact. Bodies are deliberately not reported
per mailbox and cannot be — an `.emlx` is filed under its account, not its
mailbox — so body coverage stays an account-level figure.
pyapple mail coverage
pyapple mail coverage --mailboxes`calendar coverage` is the same idea one app along. `calendars` lists names and
says which are writable; nothing said what is *in* them, so there was no way to
learn the bounds of the store without guessing a window and inferring from what
came back. It reports per calendar — empty ones included — how many
occurrences fall in a window it states in the result, how many distinct
repeating `series` those occurrences are (a weekly item is 52 occurrences of one
thing), and the earliest and latest it saw.
Two properties are worth knowing before you read the numbers. `earliest` and
`latest` are exact inside the scanned window and nothing of the sort outside
it, which is why the window is a field. And each calendar carries
`before_window` / `after_window`, counted from one extra segment scanned either
side: a non-zero one is a fact — there is more, and you are looking at the edge
of the question rather than the edge of the data. A zero is weaker and means only
that the adjacent segment holds nothing.
The default window is 20 years back and 5 years forward. On a store of 15
calendars that is 7 segments, 4,788 occurrences and 0.378s; a whole century is 33
segments and 1.08s, so widening it is affordable. `seconds` in the result is the
real elapsed time of the call that produced it.
pyapple calendar coverage
pyapple calendar coverage --from-date 2026-01-01 --to-date 2026-12-31`mail --mailbox INBOX` used to answer about the wrong mail
Gmail stores each message once in `[Gmail]/All Mail` and records membership in a
labels table, so the Gmail INBOX mailboxes hold zero rows. `unread --mailbox
INBOX` reported 2 unread to a user with 6, and `search --mailbox Sent` found the
Exchange sends and none of the 1,293 Gmail ones. The predicate follows labels
now, and every result says which label matched.
`--account` is fixed in the same place: it was accepted, documented, and then
dropped, so `--account` returned every account's mail. Scoping needs a name-to-uuid
resolution that was never done. An account name nothing matches now raises rather
than silently widening to everything.
This is worth knowing because it changes what a result *means*, not just how many
there are.
About one contact card in nine cannot be written at all
**20 of 179 cards on the development machine — 11.2% — refuse every write
through the Contacts framework** with Cocoa error 134092, and the refusal is
stable per card rather than intermittent: 5 attempts of 5 fail on an affected
card, 0 of 5 on each control.
Ruled out by driving each one: the fetch key set, staleness, the container or
account, unification and linked cards, images, and the kind of change. **Nothing
`CNContact` exposes tells the two groups apart**, so it cannot be pre-flighted
and retrying is pointless.
The one good property, verified on every failure measured: it is **total, never
partial** — the card is byte-identical afterwards. `add`, `addTo`, `rename` and
`delete` refuse with the cause named and tell you to edit that card in the
Contacts app, which still can.
`messages schedule` cannot work, and says so
`Messages.sdef` declares exactly three commands — `send`, `login`, `logout` —
and none of them takes a date. The words "schedule", "later" and "delay" appear
nowhere in the dictionary. The `schedule_state` column in `chat.db` looks like a
way in until you notice that every row carrying it also carries a send error.
So `schedule` refuses. It is kept on the surface rather than removed because a
model asked to send something at 7am, finding no such operation, is likely to
reach for `send` — and sending immediately instead of at the requested time is
the failure that would actually hurt.
Ambiguity is refused, not resolved
`contacts`, `notes` and `reminders` all match by text. When more than one thing
matches, the operation refuses and names the candidates rather than picking
one. Completing, deleting or renaming the wrong item makes it vanish with nothing
to explain why, and the caller is the only one who can tell which was meant.
`reminders` gives every result an id for this reason — pass `--id` and the
question does not arise. `calendar`'s writes sidestep it entirely by requiring
`--event-id`, which `list` and `search` print.
`calendar free` returns gaps, and ignores all-day entries
A list of events is not the answer to "am I free on Thursday": the caller would
have to know the window, subtract every event, know which of them actually
occupy the person, and know the working day. Three of those four are things the
tool knows and the caller does not.
All-day entries do not count as busy unless you pass `--include-all-day`, and
neither do events marked free or cancelled. Without that rule the development
machine reports zero free time on 211 of the next 365 days, every one of them
a holiday or a birthday.
pyapple calendar free --from-date 2026-08-06 --to-date 2026-08-08 --duration 60`calendar create --invitees` genuinely mails people
Attaching invitees turns an event into a meeting and sends an invitation to each
address, from your account, immediately. It is the one calendar write still on
Apple Events, because `EKParticipant` has no public setter — so it is also the
one that needs an Automation grant. The CLI flag says so in its own help text.
Two control characters cannot be stored, and you are told when they are dropped
`U+001E` and `U+001F` are reserved. Every refusal and hint this package writes
marks its parameter names with them, so that one sentence in a handler can print
as `--delete-notes` in a shell and `delete_notes` to a model — which means text
that arrives carrying them would be spelled the same way. Measured: a reminder
named `shop ␞account␟ now` was stored verbatim by EventKit and came back out of
the CLI as `Created reminder 'shop --account now'` — a flag this tool never
offered, written by whoever typed the reminder.
So they are removed from every argument on every operation, before anything
is stored or matched — note titles and bodies, folder names, calendar titles,
locations and notes, invitee addresses, reminder names and notes, contact
fields, mail subjects, bodies and recipients, message text, and search terms.
The write still happens; nothing is refused over two non-printing bytes. What
changed is that it is no longer silent:
$ pyapple reminders create --name "shop ␞account␟ now"
note: Removed 2 reserved control characters (U+001E, U+001F) before storing: 2 from --name. What was stored differs from what was supplied — …
Created reminder 'shop account now'Under `--json` the same fact is a `removed_control_characters` object mapping
each parameter to a count, present only when something was removed. Over MCP the
sentence is appended to the tool's answer, since that surface renders text and
nothing else.
Nothing else in your text is altered. Quotes, backslashes, emoji and
bidirectional overrides are stored exactly as sent; a terminal whose encoding
cannot render a character will print `\uXXXX` for it, but that is the display,
not the store.
Command Line
`pyapple` drives this Mac's Messages, Mail, Contacts, Reminders, Calendar, Notes,
Maps and Shortcuts from a shell, and answers two questions about the machine
itself — `presence` and `doctor`. It is the same integration code the MCP server
calls — not a reimplementation — so the two cannot drift.
pyapple --help
pyapple mail --help
pyapple mail draft --helpEvery tool and every operation has its own `--help`, and the tool-level help is
where the useful caveats live.
Start with `messages conversations` rather than a chat id you do not have: it
finds a thread by a person's name, a group's name, or by naming two people who
share one, and prints the id `messages read` wants.
pyapple messages conversations --query "book club"2 conversations:
[67] Book Club (group: Ada Byron, Grace Hopper, Alan Turing) - 412 messages, last 2026-08-04 19:22, 3 unread
[104] Ada Byron (1:1) - 88 messages, last 2026-07-30 08:11pyapple messages read --chat-id 67 --limit 20
pyapple contacts lookup --phone "+15550000000"
pyapple reminders list --list-name School
pyapple maps directions --from "Apple Park" --to "San Francisco Airport"The tools
88 operations. The class column is what
the permission policy gates on, and it is worth reading
alongside the operation: `mail open` is a write because it marks the message
read, while `reminders open` is a read because showing a reminder changes no
reminder.
Fifteen operations are spelled with a hyphen on the command line and in camelCase
in the policy string and the MCP schema. **Both spellings work as CLI
subcommands** — with one exception, `messages send-attachment`, which does not
answer to `sendAttachment`. The camelCase form is canonical, and it is the one
`pyapple perms` prints. One more differs outright: `pyapple mail read` is the
operation the schema and the policy call `readMessage`, because `read` was the
name a person reaches for and `mail read` cannot be confused with anything else
on the command line.
`contacts` — 6
| Operation | Class | What it does |
|---|---|---|
| `search` | read | find people by name, or list everyone with a number |
| `lookup` | read | number or address to person — the direction you need when something arrives from a number |
| `add` | write | add a contact |
| `addTo` / `add-to` | write | add a number or address to an existing card, rather than making a second card for the same person |
| `rename` | write | correct or complete a contact's name |
| `delete` | write | delete a contact |
`notes` — 14
| Operation | Class | What it does |
|---|---|---|
| `search` | read | search notes |
| `list` | read | list notes |
| `view` | read | print one note in full |
| `folders` | read | list note folders, with their nesting and each one's own note count |
| `create` | write | create a note in a folder that already exists |
| `append` | write | add a line to an existing note |
| `rename` | write | rename a note |
| `replace` | write | replace text inside a note |
| `setBody` / `set-body` | write | replace a note's whole body |
| `move` | write | file a note in another folder |
| `folderCreate` / `folder-create` | write | create a folder, optionally nested inside another |
| `folderRename` / `folder-rename` | write | rename a folder, leaving everything in it alone |
| `folderDelete` / `folder-delete` | write | delete a folder — see the interlock before using it |
| `delete` | write | delete a note |
**`move` is the one note write that is not behind the round-trip gate, and that
is correct rather than an oversight.** AppleScript can move a note object without
reading or rewriting its body, so the script contains no `body of` at all and
there is nothing for the gate to protect — the images, links and checklists it
exists to save are never touched. Applying it anyway would refuse exactly the
notes most worth filing, and it is why `move` is also the only write here that
works on a password-protected note. An unknown destination folder is refused with
the real ones named.
`append`, `rename`, `replace` and `setBody` refuse notes they cannot edit safely
— see
Notes editing refuses more than it accepts,
which also covers why `create` refuses an unknown folder.
A folder's `notes` count is its own and excludes its subfolders'. That is Notes'
own accounting rather than a choice made here — measured, `count of notes` on a
parent whose child separately held one note returns one — and it is the more
useful of the two, because a folder's own count is what changes when you file
something into it. `--include-deleted` adds Notes' trash, which is off by default
since a listing is what you pick a target from.
A folder rename is durable, which is worth stating because the note case is not:
Notes re-derives a note's name from the first line of its body, so a renamed note
reverts the moment anything writes to it. A folder has no body to derive from,
and driving it confirmed the difference — the folder's id was unchanged, and a
fresh `osascript` process looking it up by that id read back the new name, still
did so after a note was created inside it.
`messages` — 9
| Operation | Class | What it does |
|---|---|---|
| `conversations` | read | find conversations by name, group or participant |
| `read` | read | read one conversation, whole |
| `search` | read | search the text of messages |
| `attachments` | read | what a thread was sent, and whether the bytes are here |
| `unread` | read | unread incoming messages across every conversation |
| `open` | write | show a conversation in Messages, which also clears its badge |
| `send` | send | send a message |
| `sendAttachment` (CLI: `send-attachment`) | send | send a file into a thread that already exists |
| `schedule` | send | refuses — Messages has no such verb |
`send --chat-id` is the only way to reach a group: sending to one participant's
number starts a separate one-to-one, and nothing in the result would say the
group never saw it.
`send-attachment` is the most dangerous operation in the package — it takes a
path from a caller that may have been reading untrusted text a moment ago and puts
those bytes into somebody else's hands. Three things stand in the way and each
refuses rather than adjusts. The file must resolve into your home directory, a
temporary directory or a mounted volume, must not be hidden or under `~/Library`
(bar the attachments you were sent), and must be an existing, readable, non-empty
regular file — judged *after* symlinks are followed. The recipient must be a
conversation that already exists here: unlike `send` there is no `buddy`
fallback, because `buddy` resolves an address that was never in a conversation,
and that is exactly the route by which a handle lifted out of an injected email
becomes a recipient. And a timeout raises rather than reporting failure, since a
script killed mid-send may already have dispatched the file.
There is deliberately no caption. A file and a caption are two Apple Events
and the second can fail after the first has arrived, which leaves the caller
unable to say what the other person is looking at. Send the file, then call
`send` for the words: two calls, two answers, and it is obvious which failed. The
result reports the resolved absolute path that was actually sent, not the name it
was asked for.
`read` used to be "the most recent N, with no way to tell". It reports `total`
and `truncated` now, and `--since` / `--until` are a half-open window —
`since out.json || echo "could not read the mailbox"
Diagnostics go to stderr in text mode too, so a human watching the terminal still
sees what went wrong while a pipe stays clean.
### Flags with no MCP equivalent
A few things the CLI can do are not in the tool schema, so there is no point
looking for them there:
| Flag | On | What it does |
| --- | --- | --- |
| `--all` | `mail reply`, `mail reply-draft` | reply to everyone, not just the sender |
| `--select` | `mail open` | highlight the message in the main window instead of opening its own |
| `--unread` | `mail mark-read` | mark it *unread* instead; the MCP operation only marks read |
| `--mailboxes` | `mail coverage` | report every mailbox. Over MCP, `mailbox` moves the report one level down but only for the mailboxes whose name matches it |
| `--include-deleted` | `notes folders` | include Notes' trash; the MCP operation always leaves it out |
### Drafting a reply
`mail reply-draft` composes the reply into Drafts instead of transmitting it,
and `mail reply --draft` is the same thing under the spelling that shipped in
1.4.0. Both are gated as the operation `replyDraft`, which is `draft`-class, so:PYAPPLE_PERMS="mail=read+draft" pyapple mail reply-draft \
--subject "Thursday" --body "Works for me."
is allowed, while `pyapple mail reply` under the same policy is refused with
exit `4`. That split is the point: an assistant granted `mail=read+draft` can
answer your mail without being able to send the answer, and most of what an
assistant would compose is a reply rather than a new message.
It cuts the other way too, which is worth knowing before it surprises you:
`mail=read+send` can transmit a reply and cannot file one, because the class
belongs to the operation rather than to the intent behind it.
**A reply always opens a compose window now**, and this is a change in 1.6.0. The
body can only be put into a reply by pasting it — see the Accessibility grant
above — and a paste goes to the frontmost window, so there is no quiet path left.
`--show` no longer suppresses the window; it means *leave Mail in front
afterwards*, and without it the application that was in front is put back. A
drafted reply leaves its window on screen and only you can close it. A delivered
one also leaves a copy in Drafts, which is Mail's autosave and the same litter
`mail send` has always produced.
**Naming the message.** Prefer `--id`:pyapple mail search "Thursday"
#102917 [thread 58466] 2026-08-07 11:55:46 Alex .'
On `send` and `draft` the formatting **degrades**: without Accessibility, or if
the caret cannot be proved to be in the message body, the message is composed as
plain text and the result says the formatting was not applied. There is always a
correct plain body to fall back to there, because `content` works on a new
outgoing message. A reply has no such fallback and is refused instead.
Your clipboard is snapshotted before any of this — every item, every flavour, not
a transcription — and put back afterwards, whatever happens in between. The
result reports whether it went back rather than assuming so.
## Permission Policy
Both front ends read one policy object, so a restriction set for the MCP server
also binds the CLI and vice versa. One string expresses it, and it is identical
in an MCP config's `env` block and in a shell:{
"mcpServers": {
"pyapple": {
"command": "pyapple-mcp",
"env": {
"PYAPPLE_PERMS": "mail=read+draft,calendar=all,messages=read,*=read"
}
}
}
}
// Code blockexport PYAPPLE_PERMS="mail=read+draft,calendar=all,messages=read,*=read"
The grammar is `tool=class[+class…]`, comma separated. `all` is every class,
`none` removes the tool entirely, and `*` sets what happens to tools nobody
named.
> **A policy with no `*` narrows only the tools it names. Everything else keeps
> every operation.**
>
> ```bash
> PYAPPLE_PERMS="mail=read" # mail is read-only. The other nine are untouched
> PYAPPLE_PERMS="mail=read,*=read" # all ten are read-only
> PYAPPLE_PERMS="mail=read,*=none" # mail is read-only and the other nine are gone
> ```
>
> This is deliberate, and it is the single most misread thing in this file —
> two people in a row reported the first line as a bug. Writing `mail=read` says
> something about mail and nothing about your calendar, and a policy parser that
> silently disarmed the calendar would be removing a capability you never asked
> it to remove. **If you want a narrow default, say so with `*`.**
> `pyapple perms` prints which tools a policy left alone, so you never have to
> infer it from the table.
### The four classes
| Class | What it covers |
| --- | --- |
| `read` | changes nothing |
| `write` | changes this Mac, privately |
| `draft` | composes without transmitting |
| `send` | leaves the machine, reaches a person, irreversible |
The line that matters here is **not** read versus write. It is *"changes my Mac"*
against *"leaves my Mac and reaches another person"*. Deleting a note is private
and recoverable from the Trash; sending an email is neither, and no amount of
subsequent care retracts it. Those two are both writes and they belong in
different classes, which is why there are four rather than two.
**`messages` has no `draft` class**, and this surprises people. iMessage exposes
no draft object to AppleScript — there is no way to compose a message and leave
it unsent — so `messages=draft` grants nothing at all, which is the correct
amount. For that one tool the choice really is read or send.
**One `read` operation can still write, and it is named here rather than left to
be found.** `mail unread` accepts `--mark-read`, which clears the unread flag on
what it returns. The operation is classified `read` because that is its purpose
and the flag defaults to off — but it means a `mail=read` grant can change that
one piece of state. Enforcing it would need a per-argument gate, which the policy
does not have.
**`shortcuts run` is `send`, and it transmits nothing by itself.** It is the one
place a class is assigned from what an operation *could* do rather than from what
it does, because macOS gives no way to read a shortcut's actions —
[see above](#a-shortcuts-actions-cannot-be-read-which-is-why-run-is-a-send). At
any lower class, `PYAPPLE_READ_ONLY=1` would permit arbitrary user code that can
mail a stranger, and the shorthand would stop meaning what it says.
Of the 88 operations, 43 are `read`, 35 are `write`, 3 are `draft`
(`mail draft`, `replyDraft`, `forwardDraft`) and 7 are `send`.
### Confirming a send before it goes
The policy answers *may this caller send at all*. It answers it once, from an
environment variable, for the whole life of the process — which is the right
shape for a grant and the wrong shape for a decision about one particular message
to one particular person. A model granted `mail=send` is granted every future
send, including the one nobody has read yet.
`PYAPPLE_CONFIRM_SENDS=1` is the other half. **Confirmation is a phase of the
send, not a second operation:** call a send with no token and nothing is sent —
the answer is a rendering of what *would* go, plus a single-use ticket to send
exactly that.$ export PYAPPLE_CONFIRM_SENDS=1
$ pyapple messages send --to "+15550000000" --message "Hello there"; echo $?
Would send to +15550000000:
Hello there
Nothing was sent. This install requires sends to be confirmed. Show the above to the user; to send exactly it, repeat this call with --confirm-token=zMWFqAAIpRWJRvGE (good once, for 300s). Changing any part of the message invalidates the token.
0
$ pyapple messages send --to "+15550000000" --message "Hello there" \
--confirm-token zMWFqAAIpRWJRvGE
**Exit `0`, and `isError: false` over MCP.** Nothing failed and nothing is
pending: the caller asked what would happen, was told, and was handed the means to
go ahead. A caller that treats it as a failure has misread it. Under `--json` the
same fact is `sent: false` and `confirmation_required: true`, with
`confirm_token`, `expires_in_seconds` and `digest` beside it, so a script never
has to parse English to find the token.
**The binding is the point, not the pause.** A confirmation that only asks "are
you sure?" is theatre — the caller previews a polite note and then sends something
else. So the ticket carries a digest of the *resolved* payload: recipients after
lookup and handle normalisation, the subject, the body, the file actually on disk.
The second call recomputes that digest from the arguments it was given, and a
message that moved between the two is refused with the fields named, never their
values, because a refusal ends up in a log.$ pyapple messages send --to "+15550000000" --message "Different text" \
--confirm-token zMWFqAAIpRWJRvGE; echo $?
error: This is not the message that was confirmed: to, body changed. Nothing was sent. Preview again and confirm the new message.
1
| Variable | Default | What it does |
| --- | --- | --- |
| `PYAPPLE_CONFIRM_SENDS` | off | `1`, `true`, `yes` or `on` requires a ticket for every gated send |
| `PYAPPLE_CONFIRM_TTL` | `300` | how many seconds a ticket is good for. A value that is not a number falls back to the default and says so on stderr — the only thing in this package that fails soft, and it is safe only because every direction the fallback could be wrong in is the tighter one |
| `PYAPPLE_STATE_DIR` | `~/.pyapple` | where tickets live, under `/consent`, created and enforced `0700` |
Six things are worth knowing before you rely on it:
- **Gated today:** `mail send`, `mail reply`, `mail forward` (only when it
delivers), `messages send`, `messages sendAttachment`. The `draft` operations
are not gated, because a confirmation for something that reaches nobody is a
ceremony with nothing behind it.
- **A ticket file never contains the message.** Only a digest. The body of a
message somebody is composing is the most private thing this package touches,
and writing it under `~/.pyapple` would put it somewhere no Apple app put it,
outside every store macOS gates. The second call supplies the payload again, so
nothing has to be remembered.
- **It can only tighten.** There is no flag that turns the requirement off, for
the same reason `--perms` can only narrow: the first argument a prompt-injected
model would learn to pass is the one that disables the gate.
- **A ticket is good once, and it is spent before the send is attempted.** A
failed send does not give it back — that is `utils/applescript.py`'s argument
again, since a send that reported failure may already have left, and handing the
ticket back to be retried is how one approval becomes two messages. The cost is
a re-preview after a genuine failure, which is the direction to be wrong in.
- **An attachment is bound by resolved path, size and mtime, not by content
hash.** Hashing a 40MB video on every preview, to close a window in which
somebody can already write to your disk, is a bad trade. Because it is the
*resolved* path, a symlink repointed between the two calls is caught.
- **It does not stop a caller that previews and sends in the same breath**
without showing anybody. Nothing in a protocol can. What it buys is that a
client which does surface previews is now able to, and that the surfaced thing
and the sent thing are provably the same.
### Narrowing for one run
`--perms` on the command line **intersects** with the environment. It can only
take away, never grant:PYAPPLE_PERMS="*=read" is set; this does not give mail back
pyapple --perms "mail=all" perms
So someone who reached a terminal cannot undo the restriction an MCP
configuration put in place. `PYAPPLE_READ_ONLY=1` still works as the documented
shorthand for `*=read`, and it is applied first, so `PYAPPLE_PERMS` can only
narrow it further.
A malformed policy fails closed rather than failing open, and it is **exit 2** —
bad input, the same status as a bad command line, because the operation never
started. The message names the token it could not read *and which of the two
inputs it came from*:$ pyapple --perms "mail=nonsense" mail accounts; echo $?
usage: --perms: 'mail=nonsense' names an unknown class 'nonsense'; the classes are: read, write, draft, send, plus 'all' and 'none'
2
$ PYAPPLE_PERMS="mail@read" pyapple perms; echo $?
usage: PYAPPLE_PERMS: 'mail@read' is not 'tool=class[+class…]'; the tools are: contacts, notes, messages, mail, reminders, calendar, maps, shortcuts, presence, doctor, or '*' for the rest
2
That holds on every entry point — `pyapple`, `python -m pyapple_mcp.cli`,
`--version`, and `pyapple perms` itself, which is the command you would type to
find out what is wrong. Nothing is printed on stdout: a half-written table is a
promise the command cannot keep.
The MCP server refuses to start for the same reason, with the same status, and
says so on stderr where a client's log will pick it up:pyapple-mcp: not starting — PYAPPLE_PERMS cannot be read.
'mail@read' is not 'tool=class[+class…]'; the tools are: …
Fix PYAPPLE_PERMS in this server's "env" block, for example
"mail=read+draft,calendar=all,*=read", or remove it to allow everything.
There is no "could not understand, carrying on" path. A `--since` that fails open
returns too much data; a policy that fails open sends the email.
### Seeing what is in effect
The default is everything on — all **88** operations across the ten tools.
`*=read` leaves **43**. `pyapple perms` prints the effective policy and where
each part came from, which is the answer to the question that always follows a
refusal:$ PYAPPLE_PERMS="mail=read+draft,calendar=all,messages=read,*=read" pyapple perms
Effective policy
contacts read search, lookup
notes read search, list, view, folders
messages read conversations, read, search, attachments, unread
mail read+draft unread, search, searchBodies, readMessage, thread, attachments, coverage, draft, replyDraft, forwardDraft, mailboxes, accounts
reminders read list, search, lists, open
calendar all search, list, calendars, free, coverage, create, reschedule, alarms, move, delete, open
maps read search, directions, listGuides
shortcuts read list, folders, describe, open
presence read snapshot, focus, app, idle, screen
doctor read report
From:
defaults all 10 tools, every operation
PYAPPLE_PERMS mail=read+draft,calendar=all,messages=read,*=read
That policy leaves **51** of the 88 — everything the assistant needs to read your
world, draft mail rather than send it, and run your calendar outright.
A policy with no `*` gets one more paragraph, naming the tools it decided
nothing about:$ PYAPPLE_PERMS="mail=read" pyapple perms
Effective policy
contacts all search, lookup, add, addTo, rename, delete
…
From:
defaults all 10 tools, every operation
PYAPPLE_PERMS mail=read
No '*' entry, so the 9 tools the policy does not name keep
every operation: contacts, notes, messages, reminders, calendar, maps, shortcuts, presence, doctor.
Add '*=read' to narrow them, or '*=none' to remove them entirely.
Operations appear there under their canonical names, which is what a policy
string and the MCP schema both use. Fifteen are spelled with a hyphen on the
command line — `mail search-bodies`, `mail mark-read`, `mail reply-draft`,
`mail forward-draft`, `messages send-attachment`, `contacts add-to`,
`notes set-body`, `notes folder-create`, `notes folder-rename`,
`notes folder-delete`, `reminders list-create`, `reminders list-delete`,
`maps list-guides`, `maps create-guide` and `maps add-to-guide`. Both spellings
work as CLI subcommands for all of them. `readMessage` is the one that is not
merely rehyphenated: on the command line it is `pyapple mail read`.
On the server side a forbidden operation is removed from the tool's `operation`
enum, so a model cannot express it in the first place; on the CLI side it is
refused with exit `4`. Same policy, two enforcement points.
### What `tools/list` tells an MCP client
The tool annotations are **computed from the policy in force**, not hardcoded.
MCP annotates a *tool* while this package classifies an *operation*, and most of
these tools multiplex reads and writes behind one `operation` argument — so there
is no honest fixed answer to "is `mail` read-only?". Under `PYAPPLE_READ_ONLY=1`
it genuinely is, its enum says so, and `readOnlyHint` says so too; under a full
grant it does not. A hardcoded annotation would be wrong in one of those two
worlds, and the one it would be wrong in is the world where a client trusted it
and skipped a confirmation.
`openWorldHint` is true wherever a call can reach something that is not this Mac:
any tool retaining a `send`, plus `maps` in every policy, because its *reads*
geocode over the network. None of this is load-bearing for safety — a forbidden
operation is already unrepresentable — and these are hints a client may ignore.
They exist so that a client which does read them reaches the same decision this
package would.
## Development
### Local Setup
1. **Clone the repository**:git clone https://github.com/54yyyu/pyapple-mcp.git
cd pyapple-mcp
2. **Install dependencies**:pip install -e ".[dev]"
3. **Run the development server**:python -m pyapple_mcp.server
4. **Or run the CLI from the source tree**, without installing the console
script:python -m pyapple_mcp.cli --help
python -m pyapple_mcp.cli perms
### Running the testspytest
The suite builds its own Mail store in a temporary directory and reads nothing
of yours, so it needs no mailbox, no permissions and no network.
Two checks are worth knowing about if you add an operation. `tests/test_policy.py`
reads the operation list out of `server.py` and fails if it disagrees with
`policy.py`, so an operation with no permission class is caught when it is
written rather than when it is used. `tests/test_cli.py` then walks every leaf
the CLI parser accepts and asserts each lands on the correct side of the
read-only gate — which also catches a subcommand spelled differently from the
server's operation name, since the gate looks that name up in the policy and a
`send-message` where the server says `send` would be a write waved through.
### Testing with the MCP Inspectornpx @modelcontextprotocol/inspector pyapple-mcp
## Requirements
- **macOS 10.15+** (Catalina or later)
- **Python 3.10+**
- **Appropriate permissions** — see below. Fewer than you might expect: most of
this package now goes through system frameworks rather than through the apps
## Permissions Setup
These are macOS's own permissions — what the operating system will let the
process touch. They are a different thing from the
[Permission Policy](#permission-policy) above, which is what *you* choose to
allow out of what macOS has already granted. You need both: macOS decides
whether the mailbox can be opened at all, and `PYAPPLE_PERMS` decides what may
be done with it.
There are two kinds, and they are granted in different places:
**Privacy grants** — Contacts, Calendars, Reminders, and Full Disk Access for the
Mail and Messages databases. These cover almost everything, including every
`contacts`, `reminders` and `calendar` write, because those go through the
Contacts framework and EventKit rather than through the apps. Full Disk Access
also covers `presence focus`, which is the only presence operation that costs
anything; the other four read from a process holding no grant at all.
**`pyapple doctor report` is how you find out which of these you have.** It names
the missing grant per tool and where to click, it never prompts, and where it
cannot tell it says *undetermined* with the reason rather than guessing.
**An Accessibility grant** — new in 1.6.0, and load-bearing rather than
optional. `mail reply`, `mail reply-draft` and anything passing `--html` put the
body on the pasteboard and paste it into the compose window, because macOS
offers **no scripting route to the body of a reply and none to rich text at
all**: Mail's own dictionary describes `html content` as *"Does nothing at all
(deprecated)"*, and `content` is not a property of `outgoing message` — it works
on a new message and destroys the quoted original on a reply. Pasting needs
System Events to be allowed to send keystrokes.
Grant it in **System Settings → Privacy & Security → Accessibility**, to whatever
runs `pyapple` — your terminal, or your MCP client — and to *System Events* if it
is listed. Without it, a reply is **refused** rather than composed empty, and
`--html` on `send`/`draft` falls back to plain text and says so. Neither one
fails quietly, which is the whole reason this section exists.
**Automation grants** — *"control Mail"*, *"control Messages"*, *"control
Notes"*, *"control Calendar"*. These are the ones that pop up a dialog naming
another application. You need them only for what the table in
[Limits worth knowing](#almost-nothing-launches-an-app-any-more--but-mail-messages-and-notes-still-do)
lists: all of `notes`, mail's writes and its `mailboxes`, messages' `send`,
`sendAttachment` and `open`, `calendar open` and `create --invitees`, and
`reminders open`. Nothing in `contacts`, `maps`, `shortcuts`, `presence` or
`doctor` will ever ask.
The prompts are per-application, so granting them to Claude Desktop does not
grant them to your terminal. Running `pyapple` for the first time will prompt
again, on behalf of whichever terminal you ran it from.
## Troubleshooting
### Common Issues
**Permission Denied Errors**:
- Go to **System Settings > Privacy & Security**
- Grant access to the required applications
- Restart Claude Desktop
**Module Import Errors**:
- Ensure you're running on macOS
- Install PyObjC frameworks: `pip install pyobjc`
**AppleScript Execution Errors**:
- Check that the target applications are installed
- Verify automation permissions in System Settings
**Setup Issues**:
- Run `pyapple-mcp-setup --help` for setup options
- Check that pyapple-mcp is in your PATH: `which pyapple-mcp`
- Use `pyapple-mcp-setup --config-path /path/to/config` for custom config locations
### On the command line
**`refused: … is a 'write' operation` (exit `4`)**:
- Something narrowed the policy. `pyapple perms` prints what is in effect and
which setting produced it — an environment variable, a `--perms` on this
command line, or `--read-only`
- Remember that `--perms` can only take away. If the environment already set
`PYAPPLE_PERMS`, no flag will widen it
**`cannot read …` (exit `3`)**:
- This is macOS refusing, not the policy refusing. **Run `pyapple doctor report`**
— it names which grant is missing for that tool and where to click, and it
distinguishes a grant that is off from one it could not determine
- Check **System Settings > Privacy & Security** for the terminal you ran it
from, which is granted separately from Claude Desktop
**`Refused; edit it in Notes instead` on a note edit**:
- Not a bug. The note holds an image, attachment, link, heading or list, and
writing its body back would destroy that. See
[Notes editing refuses more than it accepts](#notes-editing-refuses-more-than-it-accepts)
**`Contacts will not save any change to …` (Cocoa error 134092)**:
- Also not a bug, and retrying will not help. A minority of cards refuse every
framework write, permanently. Edit that one card in the Contacts app
**A message you sent turning up in Drafts**:
- Expected, and it was still sent. Mail autosaves outgoing messages; see
[Every mail `send` leaves a copy in the sender's Drafts](#every-mail-send-leaves-a-copy-in-the-senders-drafts)
**`scan INCOMPLETE` from `search-bodies`**:
- The full line reads `N matches, scan INCOMPLETE: read the newest X of Y
candidates, stopped at --scan-limit X`, and a zero under it says nothing about
whether the term is in your mail — only that it was not found *yet*. The
complete form is `N matches, scanned all Y candidates`, and a zero under *that*
is an answer. In `--json` the flag is `scan_truncated`, and `total` is `null`
under a cut scan because the match count covers only what was reached. Both exit `0`: the
status answers "did the read run", and the completeness of the scan is carried
in the payload rather than in `$?`
- Narrow it with `--since` or `--account`, or raise `--scan-limit`. No setting
makes a body appear that was never downloaded — run `pyapple mail coverage` to
see which accounts have any
**An empty result you did not expect**:
- Check `$?` before believing it. A failed read exits non-zero and writes
nothing to stdout; a genuinely empty mailbox exits `0` and, under `--json`,
prints `{"results": [], "total": 0, "truncated": false, …}`. Never treat a
missing result as an empty one
- And check `truncated` before believing a *short* result. A read that was cut
says so there; `total` says how much it was cut from
**`usage: since must be an ISO 8601 timestamp …` (exit `2`)**:
- `--since` takes `2026-08-05` or `2026-08-05T09:00:00`, not prose. An
unparseable value is an error rather than a silently unfiltered result
### Logging
Both front ends send every log line to **stderr**, which is what keeps the CLI's
stdout carrying the result and only the result. The server logs at `INFO`; the
CLI logs at `WARNING`, so in normal use it is silent until something goes wrong.pyapple mail unread 2>/dev/null # result only
pyapple mail unread >/dev/null # diagnostics only
There is no environment variable for verbose output. To see the traceback behind
an unexpected failure, lower the level in-process — `force=True` matters, since
importing the package configures logging first:python -c "
import logging, sys
from pyapple_mcp.cli import main
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr, force=True)
sys.exit(main(['mail', 'unread']))
"
`main()` returns the exit status rather than raising, which is also how the tests
drive a command without leaving the process.
## Architecturepyapple-mcp/
├── pyapple_mcp/
│ ├── __init__.py
│ ├── server.py # MCP server front end
│ ├── cli.py # command line front end
│ ├── handlers.py # the shared handler objects and the policy
│ ├── policy.py # permission classes, parsing and intersection
│ ├── consent.py # the two-phase send gate: tickets bound to a payload digest
│ ├── outcomes.py # one classifier for "did this fail", shared by both front ends
│ ├── setup_helper.py # setup and configuration helper
│ └── utils/
│ ├── __init__.py
│ ├── applescript.py # AppleScript execution
│ ├── calendar.py # Calendar, through EventKit
│ ├── contacts.py # Contacts, through CNContactStore
│ ├── doctor.py # which macOS grants this process holds, probed without prompting
│ ├── mail.py # Mail: reads from the Envelope Index, writes over Apple Events
│ ├── maps.py # Maps, through MapKit
│ ├── messages.py # Messages: reads from chat.db, sends over Apple Events
│ ├── notes.py # Notes, over Apple Events — the only route there is
│ ├── pasteboard.py # snapshot and restore, for the paste routes in mail
│ ├── phrasing.py # parameter naming and the stderr logger
│ ├── presence.py # Focus, frontmost app, idle, screen — read only, no setter
│ ├── reminders.py # Reminders, through EventKit
│ ├── shortcuts.py # Shortcuts, through /usr/bin/shortcuts — a subprocess
│ ├── timeparse.py # ISO 8601 parsing for --since
│ └── truncation.py # the {results, total, truncated, more_hint} envelope
├── tests/
├── requirements.txt
├── README.md
├── LICENSE
└── pyproject.toml
The shape worth knowing: **`cli.py` contains no Apple logic**. Every query,
AppleScript and framework call lives in `utils/`, and both front ends call the
same handler objects out of `handlers.py` — not two copies that can drift out of
agreement. `policy.py` is imported by both and by neither's dependencies, which
is what lets one permission string mean the same thing in an MCP config and in a
shell.
`utils/applescript.py` is smaller than it looks and carries one rule worth
repeating: a write that hits its timeout raises rather than returning
`success: False`, because a script killed mid-run may already have done what it
was asked. A caller that retries on a false negative repeats a mutation that
already happened.
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature-name`
3. Make your changes
4. Run tests: `pytest`
5. Submit a pull request
## License
MIT License - see [LICENSE](LICENSE) file for details.
## Acknowledgments
- Inspired by the original [apple-mcp](https://github.com/dhravya/apple-mcp) TypeScript implementation
- Built with [FastMCP](https://gofastmcp.com)
- Uses PyObjC for macOS system integrationFrequently asked questions
What is pyapple-mcp?
pyapple-mcp is MCP server and CLI for seven macOS apps — Messages, Mail, Notes, Calendar, Reminders, Contacts and Maps. Native frameworks, honest truncation.
How do I install pyapple-mcp?
Open the GitHub repository and follow its README. Most MCP servers are added to your client's MCP config, then called by your agent.
Is pyapple-mcp open source?
Yes — it is hosted on GitHub at https://github.com/54yyyu/pyapple-mcp and has 17 stars.
Related MCP tools
An AI Gateway, registry, and proxy that sits in front of any MCP, A2A, or REST/gRPC APIs, exposing a unified endpoint with centralized discovery, guardrails and management. Optimizes Agent & Tool calling, and supports plugins.
AI-powered OSINT agent with interactive REPL, MCP server, and CLI. 19 tools. Works with Claude, GPT-4, or local models. For authorized security research only.
Production-grade MCP server giving Claude 27 security intelligence tools across 21 APIs — CVE lookup, EPSS scoring, CISA KEV, MITRE ATT&CK, Shodan, VirusTotal, and more.
Open-source coding agent memory. Records issues, attempts, fixes and decisions, then warns your agent before it repeats an approach that already failed. Native MCP server for Claude Code, Cursor, Antigravity and Codex. 100% local, no cloud, no telemetry. MIT.
An LLM agent that conducts deep research (local and web) on any given topic and generates a long report with citations. Built for the Model Context Protocol to
Automate browser based workflows with AI
Run your own MCP server? See who uses it and what to fix.
Measure it with TrackMCP