PyApple MCP Tools

by 54yyyu

Not rated
GitHub

About

Python tools for MCP that integrate with native Apple applications like Messages, Notes, Mail, and more on macOS.

Details

Author
54yyyu
Categories
Productivity, Other, Automation, Communication, Community
Tags
#macos, #notes

Setup

Install PyApple MCP Tools in your MCP client (Claude Desktop, Cursor, Windsurf, and others).

Repository: https://github.com/54yyyu/pyapple-mcp

Follow the installation instructions in the repository README, then restart your MCP client.

Python tools for MCP that integrate with native Apple applications like Messages, Notes, Mail, and more on macOS.

A Python implementation of Apple-native tools for theModel 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, andpyapple, 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 underThe tools, and what they refuse to do is underLimits 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.mdin 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.

- Messages: find conversations, read a thread whole, search message text, list what a thread was sent, and send — text or a file. Reads go throughchat.dbdirectly and are scoped bychat, 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 thatrefusesany 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.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,bodysearch, read one message whole by id, a wholethreadin order, what isattachedand 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 andnotan 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--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, and an optional
two-phase gatethat makes any send show what it would send first — seePermission Policy

# Install pyapple-mcp pip install pyapple-mcp # Run the setup helper to configure Claude Desktop pyapple-mcp-setup

- Find your pyapple-mcp installation
- Locate your Claude Desktop config file
- Automatically add the configuration
- Display helpful setup information

Configure Claude Desktopby editing~/Library/Application Support/Claude/claude_desktop_config.json:

{ "mcpServers": { "pyapple": { "command": "pyapple-mcp" } } }

Restart Claude Desktopto load the new configuration.

Nothing else is needed to use the CLI;pip install pyapple-mcpis the whole installation.

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.

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
Get directions from "Apple Park" to "San Francisco Airport"

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"

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 report

Every one of these was measured against the real applications, and each is something you need to knowbeforerelying 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 contactswritesmoved from Apple Events to EventKit andCNSaveRequest; 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:

mail threadandmail attachmentsare on the index-and-filesystem side with the other reads: no Apple Event, Mail is not launched, and nothing is marked read.reminders listCreateandlistDeleteare EventKit like the rest of that tool.

shortcutsis 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 nocom.apple.security.automation.apple-events, andotool -Lfinds no AppleEvents and no Carbon — it reaches its daemon overmach-lookup.shortcuts listandshortcuts run <a name that does not exist>were both run with Shortcuts.app not running; neither prompted and neither started it.shortcuts openis the exception and puts the editor on screen, which is whatcalendar openandreminders opencost.

Two honest qualifications, because that is easy to over-read.Wepay no permission; theshortcutdoes — one whose actions touch Contacts or Photos raises those prompts against Shortcuts, and this package cannot enumerate what a shortcut will reach, so it cannot warn you either. And a shortcut containing an interactive action will put UI on screen and block until somebody answers, which is whyrunhas a timeout at all.

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 includingfolders. That is a platform limit, not an omission.

mail accountscame off that list. It used to ask Mail whether each account was enabled; it now reads the same indexcoveragereads 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 coverageandmail readMessagenever touch Mail either — index and filesystem only.

maps pinis the odd one out: it opens a Maps window, but throughopen(1)and amaps://URL rather than an Apple Event, so it needs no Automation grant. It opensbehindwhat you are working in unless you pass--show. The window cannot be closed again from here — measured,close window 1blocks 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, sothe 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:

Soappend,rename,replaceandsetBodyrefuseany 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, socreatethenappendworks as you would expect.

createanddeleteare unaffected by that gate: neither reads a body back.

createhas 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 foldersis the listing that lets you check first, andnotes folder-createis 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 tofolder-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 moveto file the note where it belonged andfolder-deleteto remove what the typo made.

notes folder-deletedestroys 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 was3 before and 3 after, and a search ofevery note— which does reach the trash, since the same query found a trashed note by name in the same run — matched0of 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 refusedunless you pass--delete-notes Nwith the folder'sexactcount.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 whereid ofthat container raises, and there is no argument that gets past this.

The trash and any account's default folder are refusedfor what they are. There is one of each per account and nothing here needs to remove them.

The result carriesmay_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 anemptyfolder 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, andnotes_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 shipsno scripting dictionary— there is no.sdefanywhere in it — and Favourites and Guides have no public API and no readable on-disk copy; the data is CloudKit-backed and opaque. Sosave,listGuides,createGuideandaddToGuidecannot be implemented at all.

They report failure and hand back a durablemaps.apple.comlink 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

listGuidesin 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 anddirectionsalways needs an explicit--from.

A shortcut's actions cannot be read, which is whyrunis asend

/usr/bin/shortcutshas 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, soshortcuts describereportsactions: nullandactions_known: falseand 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.sqlitecarries aZACTIONSDESCRIPTIONcolumn, which is the obvious way round it. It was populated for2 of 7shortcuts 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 forrunbeing classifiedsendrather thanwrite. 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=1is documented as=readand it is the flagship safety setting, and ifrunwere anything belowsendthat 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-nameignores a folder it does not recognise and returns the entire library at exit 0. Measured —-f "Starter Shortcuts"gave 3,-f nonegave 4, and-f "@@@nope@@@"gave all7, with nothing on stderr. Sopyapple shortcuts list --foldervalidates the name against the folder listing first and refuses with the real names, at exit2. 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 fivepresenceoperations cost the usernothing at all— that is measured rather than assumed, from an unsigned.applaunched through LaunchServices so that its responsible process was itself and it held no TCC grant of any kind. That control was deniedchat.dbwithEPERM, which is what makes the rest of the row mean something: from it,NSWorkspace.frontmostApplication(),CGEventSourceSecondsSinceLastEventTypeandCGSessionCopyCurrentDictionaryall answered.

presence focusis the exception. Focus state lives in~/Library/DoNotDisturb/DB, which is behindFull Disk Access, and Apple documents neither the directory nor the JSON in it. So every presence value carries aprovenancefield, and Focus's saysreverse-engineeredwhile the other three saydocumented. 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 onlyisFocusedas an optional Bool andnever names the mode, and it is gated on anNSFocusStatusUsageDescriptionin anInfo.plist, which apip-installed console script does not have. It would cost a new dependency and an app bundle to learn strictly less.

presence snapshotis the one to poll and the only one that survives a partial failure: every field carries its ownavailable, so a field that could not be read stays distinct from a field that is genuinely off, and the envelope carriesunavailableandcomplete. 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 deleterefuses the accounts where it would not be recoverable

Mail'sdeleteverb is what the Delete key is: on an ordinary account it files the message in that account's Trash, which is awrite— private, and undoable.

But that is a per-account setting, not a fact about Mail.Mail.sdefdeclaresmove deleted messages to trashon theaccountclass, and with it off a deleteerases. 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 accountbeforetouching anything and refuses when it is off, pointing atmoveinstead. 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 quitandempty 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 moveis 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 (Senton a machine with three accounts is three mailboxes), and a destination it could notcheck*, 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 astat, 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 attachmentscarriesavailable_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 sametransfer_stateas the 5,365 that do — so the column cannot be trusted and presence is astat.Nonemeans no path was recorded at all, which is unknowable rather than false.

mail attachmentsreports names and sizes, andsizes are not in the index: theattachmentstable 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 splitcoveragereports for bodies.A size that is not known isnull, never0.A zero-byte attachment and one whose bytes were never downloaded are different facts,on_disksays which you have, and the note says how many of each.

Every mailsendleaves 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 searchBodiesis partial by construction, and the split is per account

Bodies are not in the Envelope Index, so a body search has to open.emlxfiles 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 whycoverageexists. The three accounts on that machine are nothing like each other: one holds 31,143 indexed messages andzerobodies, 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. Runmail coveragebefore you conclude anything from an empty body search.

No reviews yet — be the first

Sign in to leave a review

Use Google, GitHub, or an email account so ratings stay tied to real people.

Email sign in

No reviews posted yet.