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
seven tools from a shell. They share one permission policy, so what you allow in
one you have allowed in both.
65 operations across seven 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,
and send. Reads go through
chat.dbdirectly 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
folders: list them, create one, rename one, delete one.
createrefuses 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-deleterefuses 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, coverage — what each account can actually answer about — send, reply, draft, reply as a draft, mark read, and list mailboxes and accounts. Reads come from the local Envelope Index rather than through the app
- Reminders: list, search, create, update, complete and delete, 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
- Command line: the same seven tools from a shell, with
--jsonoutput 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 — see Permission Policy
Quick Installation
Automated Setup (Recommended)
# Install pyapple-mcp
pip install pyapple-mcp
# Run the setup helper to configure Claude Desktop
pyapple-mcp-setup
The setup helper will:
- Find your pyapple-mcp installation
- Locate your Claude Desktop config file
- Automatically add the configuration
- Display helpful setup information
Manual Installation
-
Install pyapple-mcp:
pip install pyapple-mcp
-
Configure Claude Desktop by editing
~/Library/Application Support/Claude/claude_desktop_config.json:{ "mcpServers": { "pyapple": { "command": "pyapple-mcp" } } }
-
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 seven 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 --help
Usage 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 them
Create a reminder to "Buy groceries" for tomorrow at 5pm
When 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
See 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 |
mail |
send, reply, draft, replyDraft, open, markRead, mailboxes, and unread --mark-read |
messages |
send, open |
calendar |
open, and create --invitees |
reminders |
open |
contacts, maps |
nothing |
Notes is the exception that cannot be fixed. Notes ships no framework API at
all, so AppleScript is the only route in and Notes.app launches for every
operation including a search and including folders. That is a platform limit,
not an omission.
mail accounts came off that list. It used to ask Mail whether each account was
enabled; it now reads the same index coverage reads and reports what this Mac
actually holds per account, which is the consequence a caller cares about where
"disabled" was only a cause of it. It falls back to asking Mail only if the index
names no accounts at all, which on a machine with mail means the read failed
rather than that there are none. mail coverage and mail readMessage never
touch Mail either — index and filesystem only.
maps pin is the odd one out: it opens a Maps window, but through open(1) and
a maps:// URL rather than an Apple Event, so it needs no Automation grant. It
opens behind what you are working in unless you pass --show. The window
cannot be closed again from here — measured, close window 1 blocks until the
Apple Event times out and leaves the window where it was — so a pin is a one-way
door.
Notes editing refuses more than it accepts
Notes' HTML serialiser and its HTML parser do not agree, so the body Notes hands you is not a body it will take back. Every edit here is a whole-body rewrite, because Notes offers nothing else, so a read-modify-write is destructive. Measured, writing a body back unchanged:
| Content | What the round trip does to it |
|---|---|
| images | destroyed outright — 2 <img> before, 0 after, three clean trials |
| attachments | destroyed, even under a pure identity write |
| links | <a href="…">text</a> comes back as <u>text</u>: the text survives, the URL does not |
| headings | five input forms of <h1> all stored as a bold span |
| checklists | ten candidate markups all stored as plain <ul><li>, 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 there is no
operation here that moves a note between folders, so tidying it up is by hand.
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 Nwith the folder's exact count.Nis 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 ofthat 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: Truerather 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_recoverableisnullrather thanfalsewhen there were none to recover.
pyapple notes folders # read the count first
pyapple notes folder-delete "Scratch" --delete-notes 2
Four 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.
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
and Maps from a shell. 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 --help
Every 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:11
pyapple 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
65 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.
Eleven 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;
the camelCase one 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 — 13
| 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 |
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 |
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 — 7
| 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 |
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 |
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.
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 <= date < until — so "what did we say in March" is one call rather than a
walk. Paging backwards is the same mechanism: the result carries next_until,
and you pass it back as --until. It is deliberately not an offset, because
an offset is counted from the end of a live conversation: one message arriving
mid-walk shifts every later page and the caller both repeats and skips with no
way to notice. The trade is one duplicate at each boundary rather than a silent
gap — driven against the real store, two pages of 20 on each of the three busiest
threads overlapped by exactly the one boundary message. It is not a way to fetch a
whole conversation; the busiest thread measured holds 11,608 messages.
search matches the text of messages and is the operation that did not exist:
finding a thread previously meant already knowing who was in it, since
conversations matches chat names and participants and never text. Both
storage columns are searched, and the second is the important one — text is
populated on 21,415 of 84,402 rows and the other 62,749 live entirely in
attributedBody, a binary typedstream. Searching text alone found 171 messages
for one term where searching both found 402, so 43% recall with nothing to say
the rest existed. A blob match is only a candidate — a substring can land in the
typedstream's own class names — so every candidate is decoded and confirmed
against the text a reader would see. Measured at 0.07s over 84,402 rows.
mail — 13
| Operation | Class | What it does |
|---|---|---|
unread |
read | unread mail |
search |
read | search headers — sender, subject, recipients, attachment names |
searchBodies / search-bodies |
read | search what the messages say, not just headers |
readMessage (CLI: mail read) |
read | one message whole, by the id every other read prints |
coverage |
read | what each account, or each mailbox, can answer about |
mailboxes |
read | list mailboxes |
accounts |
read | list mail accounts, disabled ones marked |
markRead / mark-read |
write | mark a message read without opening anything; --unread for the reverse |
open |
write | show a message in Mail, which also marks it read |
draft |
draft | compose an email into Drafts without sending |
replyDraft / reply-draft |
draft | compose a reply into Drafts without sending |
reply |
send | reply in the thread of a message you name |
send |
send | send an email |
readMessage marks nothing: no read status, no open, no Apple Event of any
kind. The index is read read-only, the body comes off the disk, Mail is not
launched, and a message that was unread stays unread — which is the difference
between reading somebody's mail and rearranging it. max_chars caps the body at
20,000 and body_truncated says whether it bit. Where a body is not on this Mac
it gets a sentence naming the account rather than an empty string.
Mail ids are Envelope Index ROWIDs and Mail reassigns them. Observed inside a
single session with nothing unusual happening, ids for the same mail moved from
roughly 58,000 to roughly 102,000 because Mail reindexed in the background. So
take an id from the read in front of you and spend it immediately; do not carry
one across sessions. If mail read --id reports no such message, re-run the
search rather than retrying the number — the tool will not silently re-resolve a
stale id for you, because re-running a search can land on a different message
and quietly reading the wrong mail is worse than an error you can see.
reminders — 8
| Operation | Class | What it does |
|---|---|---|
list |
read | list reminders, with --due-before / --due-after |
search |
read | search names and notes |
lists |
read | name the reminder lists that exist |
open |
read | open a reminder in the app |
create |
write | create a reminder |
update |
write | change a reminder's name, notes, due date or priority |
complete |
write | mark a reminder done; --undo is the way back |
delete |
write | delete a reminder |
calendar — 11
| Operation | Class | What it does |
|---|---|---|
list |
read | events in a window, with recurring ones expanded into their occurrences |
search |
read | search titles, locations and notes |
calendars |
read | list available calendars |
coverage |
read | what each calendar can answer about, empty ones included |
free |
read | find gaps, not events |
open |
read | open an event in the app |
create |
write | create an event, with repeats, alarms and invitees |
reschedule |
write | change when an event happens; --span series for all of them |
alarms |
write | set or clear an event's alarms |
move |
write | move an event to another calendar |
delete |
write | delete an event |
list and search take --include-attendees (who is coming and whether they
have accepted) and --include-details (the repeat rule and alarms). Both are off
by default: attendees roughly triple the read, and attendee names and addresses
are other people's contact details, so they are pulled into a transcript only
when asked for.
maps — 7
| Operation | Class | What it does |
|---|---|---|
search |
read | search for locations; --near and --radius-meters to bias it |
directions |
read | driving, walking or transit, with ETA and turn-by-turn |
listGuides / list-guides |
read | unsupported — no API, no on-disk copy |
pin |
write | drop a pin, opening a Maps window that only you can close |
save |
write | unsupported — Favourites are unreachable |
createGuide / create-guide |
write | unsupported |
addToGuide / add-to-guide |
write | unsupported |
perms
Not a tool and not gated: it prints the effective permission policy and where each part came from. See Permission Policy.
Global flags
| Flag | Effect |
|---|---|
--json |
emit the structured result as JSON on stdout, and nothing else — rows come back as {results, total, truncated, more_hint}, see --json |
--read-only |
refuse anything that sends, creates, moves or deletes |
--perms SPEC |
narrow permissions for this run — see Permission Policy |
--version |
print the pyapple-mcp version |
They are accepted before the tool or after the operation, so both of these work:
pyapple --json mail unread
pyapple mail unread --json
Alternative flag spellings
Some flags answer to a second name, so that a reasonable wrong guess works
instead of exiting 2. Every example in this document uses the canonical
spelling — the one --help lists first — and the alias is only there to catch
you; there is one way to write these, not two.
| Command(s) | Canonical | Also accepted |
|---|---|---|
calendar list/search/free/coverage |
--from-date / --to-date |
--since / --until |
mail unread/search/search-bodies |
--since |
--from-date |
messages conversations/read/search/unread |
--since |
--from-date |
messages read |
--until |
--to-date |
reminders list |
--due-after / --due-before |
--due-from / --due-until |
maps directions |
--from / --to |
--from-address / --to-address |
calendar alarms/delete/move/open/reschedule |
--event-id |
--id |
calendar alarms |
--minutes-before |
--alarm-minutes-before |
calendar move |
--to-calendar |
--to |
calendar create |
--title |
--name |
messages read/open/send |
--chat-id |
--id |
mail search |
--conversation-id |
--thread-id |
mail mark-read |
--unread |
--undo |
contacts add-to/delete/rename |
--name |
--contact |
reminders create |
--name |
--title |
reminders complete |
--undo |
--uncomplete |
notes create |
--title |
--name |
notes rename |
--to |
--new-title |
notes folder-rename |
--to |
--new-name |
notes append |
--text |
--add |
Three things this deliberately does not do:
reminders list --due-after/--due-beforedo not collapse into--since/--until. A reminder has a creation date as well as a due date, so an unprefixed window here would be genuinely ambiguous about which one it filters, and an alias that silently picks one is worse than no alias.mail mark-read/open/replyselect by--subject, a substring match, and there is no id form. Aliasing that to--idwould invite an integer that silently substring-matches.- Nothing was renamed. Every spelling that worked before still works.
One side effect worth knowing: an alias adds a prefix, so a few abbreviations
that used to be unambiguous now are not — mail unread --f is --full-content
or --from-date and says so, and so do calendar create --n, messages read --t and calendar alarms --alarm. Spell the flag out. Two spellings of the
same flag are never ambiguous: maps directions --fro still resolves.
Exit status
The exit status is the contract, and it distinguishes failures that want different responses from you — fix the command line, grant a permission, or retry.
| Status | Meaning |
|---|---|
0 |
success |
1 |
the operation ran and failed |
2 |
bad command line, including an unparseable --since or a permission policy that cannot be read |
3 |
the store could not be read (usually a permission) |
4 |
refused by --read-only or by the permission policy |
$ 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
$ pyapple mail unread --since "next tuesday"; echo $?
usage: since must be an ISO 8601 timestamp such as '2026-08-05' or '2026-08-05T09:00:00', got 'next tuesday'
2
$ pyapple --read-only notes create --title Groceries --body Milk; echo $?
refused: 'notes create' is a 'write' operation and this policy does not grant it. Run 'pyapple perms' to see the policy and where it came from.
4
Every one of those diagnostics went to stderr, and stdout stayed empty. That is the other half of the contract.
3 has no such example, because there is no harmless way to make macOS refuse a
store you have already been granted. It is the code for a store that could not be
read at all — a permission that was never granted, or an application that is not
set up — and the message begins cannot read.
The same contract over MCP
A tools/call that failed comes back with isError: true, and it fails on
exactly the things the CLI exits non-zero on. The two front ends share one
classifier, so a caller branching on isError and a caller branching on $?
reach the same verdict about the same request.
| Outcome | CLI status | MCP isError |
|---|---|---|
| the call succeeded | 0 |
false |
| a read that found nothing | 0 |
false |
| a read that was truncated | 0 |
false |
| the operation ran and failed | 1 |
true |
| a bad request — missing parameter, unknown list | 2 |
true |
| the store could not be read | 3 |
true |
| refused by the permission policy | 4 |
true |
The two bold rows are the ones worth stating out loud: an empty result is a success. A search that matched nothing, a mailbox with no unread and a folder with no notes are all answers — the store was read, and the finding is zero. A truncated listing is a success too; the notice saying how much was cut is information about a read that worked, not a failure. Treating either as an error is the same conflation as reporting an unreadable mailbox as an empty one, just pointing the other way.
The refusal text arrives intact in content, because a model has to be able to
act on it — pass delete_notes=2 to destroy them is not a diagnostic, it is the
next call. Policy refusals reach MCP earlier than the other four: a forbidden
operation is removed from the tool's operation enum, so the request is rejected
against the schema rather than by the tool. It still arrives as isError: true.
--json, and why the statuses exist
With --json, stdout carries the structured result and nothing else, so it pipes
into jq cleanly.
A command that answers with rows answers with an envelope, and the rows are
always under results:
{
"results": [ ... ],
"total": 3631,
"truncated": true,
"more_hint": "limit=-1"
}
pyapple mail unread --limit 5 --json | jq -r '.results[].subject'
pyapple calendar list --from-date 2026-08-06 --to-date 2026-08-13 --json | jq '.results | length'
pyapple reminders lists --json | jq -r '.results[].name'
# the two facts a bare array could not carry
pyapple calendar list --from-date 2022-01-01 --to-date 2026-12-31 --json | jq '{total, truncated}'
# → { "total": 3631, "truncated": true }
The count travels with the data because otherwise it does not travel at all.
calendar list over that five-year window returns ten events and there are
3,631; before the envelope both came back as a bare array, and ten of 3,631 was
byte-identical to a complete answer of ten. The text rendering had learned to say
showing 10 of 3631 events while --json went on emitting the array — to the
callers least able to notice, since a script has no reader to be surprised.
truncated is stated on every listing, including ones that cannot truncate,
so you never have to know which operation you called before you can parse it.
more_hint is the argument that returns the rest, in its parameter form
(limit=-1); the terminal spells the same fact --limit -1.
One key, two levels, two different facts — so the row-level one was renamed.
The envelope's truncated means rows were left behind. A notes record also
carries a flag saying this note's text was cut at the character cap, and that
one is content_truncated, matching what mail search-bodies has always called
it. It used to be truncated, which was merely confusing inside results and
actively wrong on notes view --json: a single object is not wrapped, so the
note's own flag sat at the same depth an envelope's would, meaning the opposite
thing. total_chars beside it is the note's real length.
total is null on a read that stopped early and cannot say what it stopped
short of — messages search stops decoding at the limit, and a cut
mail search-bodies scan counts matches only among the messages its budget
reached. truncated is true in both cases. A null total means uncounted,
never zero.
A command that answers with one object is that object, unwrapped — mail read, calendar coverage, notes view, and every write. The rule is the
payload's own shape: rows get .results, a single record does not. Two objects
carry rows as well as facts about them, and they use the same key: messages read (rows under results, alongside chat, ambiguous, alternatives and
next_until) and mail search-bodies (rows under results, alongside
candidates, scanned, with_body and scan_truncated).
# one object, so no envelope
pyapple calendar coverage --from-date 2026-08-01 --to-date 2026-08-31 --json | jq 'has("results")'
# → false
On failure stdout is empty — not an envelope with no rows in it. A store
that could not be read must never look like a store with nothing in it:
{"results": [], "total": 0, "truncated": false} is an answer, and the store
never answered. That distinction is exactly why there are five exit codes rather
than "zero or non-zero", because a caller can branch on $? and cannot branch
on prose.
# `3` and an empty stdout, not `0` and an empty result set
pyapple mail unread --json > 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.
Drafting mail
New in 1.4.0. mail draft files the message into the sending account's Drafts
and opens no window:
pyapple mail draft --to alex@example.com --subject "Re: Thursday" \
--body "Works for me." --from you@example.com
Draft to alex@example.com saved in Drafts. Nothing was sent.
--show opts into a visible compose window instead, and it is a one-way door:
a compose window Mail has opened cannot be closed again from AppleScript, so
dismissing it is yours to do by hand. That is why filing quietly is the default.
--from is worth setting. Without it Mail picks its own default account, which
on a machine with several is a choice the caller cannot see and the recipient
can.
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"
}
}
}
}
export 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.PYAPPLE_PERMS="mail=read" # mail is read-only. The other six are untouched PYAPPLE_PERMS="mail=read,*=read" # all seven are read-only PYAPPLE_PERMS="mail=read,*=none" # mail is read-only and the other six are goneThis 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=readsays 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 permsprints 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.
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, 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 65 operations across the seven tools.
*=read leaves 30. 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, unread
mail read+draft unread, search, searchBodies, readMessage, coverage, draft, replyDraft, 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
From:
defaults all seven tools, every operation
PYAPPLE_PERMS mail=read+draft,calendar=all,messages=read,*=read
That policy leaves 37 of the 65 — 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 seven tools, every operation
PYAPPLE_PERMS mail=read
No '*' entry, so the 6 tools the policy does not name keep
every operation: contacts, notes, messages, reminders, calendar, maps.
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. Eleven are spelled with a hyphen on the
command line — mail search-bodies, mail mark-read, mail reply-draft,
contacts add-to, notes set-body, notes folder-create, notes folder-rename,
notes folder-delete, maps list-guides, maps create-guide and
maps add-to-guide — and both spellings work as CLI subcommands. 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.
Development
Local Setup
-
Clone the repository:
git clone https://github.com/54yyyu/pyapple-mcp.git cd pyapple-mcp
-
Install dependencies:
pip install -e ".[dev]"
-
Run the development server:
python -m pyapple_mcp.server
-
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 tests
pytest
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 Inspector
npx @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 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.
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
lists: all of notes, mail's writes and its mailboxes, messages' send and
open, calendar open and create --invitees, and reminders open. Nothing in
contacts or maps 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 --helpfor setup options - Check that pyapple-mcp is in your PATH:
which pyapple-mcp - Use
pyapple-mcp-setup --config-path /path/to/configfor custom config locations
On the command line
refused: … is a 'write' operation (exit 4):
- Something narrowed the policy.
pyapple permsprints what is in effect and which setting produced it — an environment variable, a--permson this command line, or--read-only - Remember that
--permscan only take away. If the environment already setPYAPPLE_PERMS, no flag will widen it
cannot read … (exit 3):
- This is macOS refusing, not the policy refusing. 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
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
sendleaves a copy in the sender's 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 isN matches, scanned all Y candidates, and a zero under that is an answer. In--jsonthe flag isscan_truncated, andtotalisnullunder a cut scan because the match count covers only what was reached. Both exit0: the status answers "did the read run", and the completeness of the scan is carried in the payload rather than in$? - Narrow it with
--sinceor--account, or raise--scan-limit. No setting makes a body appear that was never downloaded — runpyapple mail coverageto 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 exits0and, under--json, prints{"results": [], "total": 0, "truncated": false, …}. Never treat a missing result as an empty one - And check
truncatedbefore believing a short result. A read that was cut says so there;totalsays how much it was cut from
usage: since must be an ISO 8601 timestamp … (exit 2):
--sincetakes2026-08-05or2026-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.
Architecture
pyapple-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
│ ├── setup_helper.py # setup and configuration helper
│ └── utils/
│ ├── __init__.py
│ ├── applescript.py # AppleScript execution
│ ├── calendar.py # Calendar, through EventKit
│ ├── contacts.py # Contacts, through CNContactStore
│ ├── 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
│ ├── reminders.py # Reminders, through EventKit
│ └── timeparse.py # ISO 8601 parsing for --since
├── 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
- Fork the repository
- Create a feature branch:
git checkout -b feature-name - Make your changes
- Run tests:
pytest - Submit a pull request
License
MIT License - see LICENSE file for details.
Acknowledgments
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyapple_mcp-1.5.1.tar.gz.
File metadata
- Download URL: pyapple_mcp-1.5.1.tar.gz
- Upload date:
- Size: 523.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1c32e72d38a27bb0ae9c18a8b7b91be4eda1d68c1bb7fa399f6a0bd26312b8bf
|
|
| MD5 |
9807abe00afe664957bec74ebb0df9af
|
|
| BLAKE2b-256 |
593093001c2a545d351453a64217ae700bbd076e9dc215c4bbb086af36f6bfb3
|
Provenance
The following attestation bundles were made for pyapple_mcp-1.5.1.tar.gz:
Publisher:
publish.yml on 54yyyu/pyapple-mcp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyapple_mcp-1.5.1.tar.gz -
Subject digest:
1c32e72d38a27bb0ae9c18a8b7b91be4eda1d68c1bb7fa399f6a0bd26312b8bf - Sigstore transparency entry: 2363865110
- Sigstore integration time:
-
Permalink:
54yyyu/pyapple-mcp@8ee39122fa893b96120b6c8e3809d1badddf5629 -
Branch / Tag:
refs/tags/v1.5.1 - Owner: https://github.com/54yyyu
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8ee39122fa893b96120b6c8e3809d1badddf5629 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pyapple_mcp-1.5.1-py3-none-any.whl.
File metadata
- Download URL: pyapple_mcp-1.5.1-py3-none-any.whl
- Upload date:
- Size: 297.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
86b300d21af34f32384821a306b689eb3bce2f48fb3b3b10e5031ce1d62de6ae
|
|
| MD5 |
3db0248a1115f70f3a7f7b23dd33680c
|
|
| BLAKE2b-256 |
236fb27d485e863fc5050d3214ee77237648b9fc534ef64623caadde3b691adc
|
Provenance
The following attestation bundles were made for pyapple_mcp-1.5.1-py3-none-any.whl:
Publisher:
publish.yml on 54yyyu/pyapple-mcp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyapple_mcp-1.5.1-py3-none-any.whl -
Subject digest:
86b300d21af34f32384821a306b689eb3bce2f48fb3b3b10e5031ce1d62de6ae - Sigstore transparency entry: 2363865192
- Sigstore integration time:
-
Permalink:
54yyyu/pyapple-mcp@8ee39122fa893b96120b6c8e3809d1badddf5629 -
Branch / Tag:
refs/tags/v1.5.1 - Owner: https://github.com/54yyyu
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8ee39122fa893b96120b6c8e3809d1badddf5629 -
Trigger Event:
release
-
Statement type: