Skip to main content

PyApple MCP Tools

PyPI Downloads License MCP

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 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

  1. Install pyapple-mcp:

    pip install pyapple-mcp
    
  2. 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 --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
pyapple presence snapshot
pyapple doctor report

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 — 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 <a name that does not exist> were both run with Shortcuts.app not running; neither prompted and neither started it. shortcuts open is the exception and puts the editor on screen, which is what calendar open and reminders open cost.

Two honest qualifications, because that is easy to over-read. We pay no permission; the shortcut does — 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 why run has 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 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 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 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.

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 --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

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 <= 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 onetext 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 — 19

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
thread read a whole conversation, oldest first, without launching Mail
attachments read what is attached to one message, and whether the bytes are here
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
move write file a message in a mailbox that already exists
delete write move a message to its account's Trash, or refuse
draft draft compose an email into Drafts without sending
replyDraft / reply-draft draft compose a reply into Drafts without sending
forwardDraft / forward-draft draft compose a forward into Drafts without sending
reply send reply in the thread of a message you name
forward send pass a message on to somebody else
send send send an email

thread answers the question neither readMessage nor search does — what has already been said here, and did I reply. It selects on conversation_id, a column the index already carries, so it is one indexed predicate rather than a call per message: no Apple Event, Mail never launches, nothing is marked read. Re-threading by subject line instead would be wrong in both directions, measured on a real 45,628-message index: it merges 2,516 subject lines shared by more than one conversation (the worst single subject is shared by 417) and splits 354 conversations that legitimately change subject partway through, both silently. It is label-aware, so a Gmail thread living in [Gmail]/All Mail comes back whole rather than as whichever part one mailbox happened to hold. Pass --conversation-id, or --id with any message in the thread.

forward and forwardDraft are two operations rather than one with a flag, for the reason reply and replyDraft are: the policy gate reads (tool, operation) and never sees an argument, so a flag would have put composing and sending behind one grant. A covering note goes in through the pasteboard and is verified in the compose window before anything is sent, because content is not a property of outgoing message and writing to it discards the thing being forwarded — see Drafting a reply. A forward with no note is allowed, unlike an empty reply: the payload is the thing being forwarded, so an empty body is a complete message rather than the defect.

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 — 11

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
uncomplete write mark one not done again
delete write delete a reminder
listCreate / list-create write make a new reminder list
listDelete / list-delete write delete a list and everything on it — see the interlock below

uncomplete is its own operation rather than only a flag (complete --undo still works, and is the same code path — complete(completed=False), which is how the ambiguity refusal and the not-found behaviour stay identical between doing and undoing). Without it the route a caller takes is delete-and-recreate, which silently loses the due date, the priority and the notes.

listCreate refuses rather than choosing an account when more than one could hold the list. A list in the local account never leaves the Mac; a list in iCloud reaches the user's phone. Those are different answers to "add a shopping list", and the one who asked cannot see which they got. It also refuses a duplicate name — not because Reminders minds, it does not, but because every operation here resolves a list by title, so a second list of the same name makes both of them unaddressable from this tool.

listDelete copies notes folder-delete's interlock exactly, because the loss is the same kind: removeCalendar_ has no trash behind it and nothing in this package can put the reminders back. A non-empty list is refused unless you pass --delete-reminders N with its exact count, and the count is re-taken immediately before the delete so a number that has moved refuses rather than destroys. A boolean could be set by a model that never looked at the list; a number cannot be guessed. The default list is refused for what it is.

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

shortcuts — 5

Operation Class What it does
list read every shortcut, or one folder's
folders read the library's folders
describe read what is knowable about one without running or opening it
open read open one in the Shortcuts editor — the one operation here that puts an app on screen
run send run one and report what it produced

folders is its own operation rather than a flag on list because a folder cannot be run and does not answer to --folder: a flag that changes what a row is publishes two schemas under one enum member.

run --timeout hitting its limit is an error, not a failure report, on the same argument utils/applescript.py makes: a shortcut killed mid-run may already have done what it was asked. --input is staged through a temporary file, because the Shortcuts CLI's -i takes a path and reads nothing from stdin.

pyapple shortcuts folders
pyapple shortcuts list --folder "Starter Shortcuts"
pyapple shortcuts describe "Start Pomodoro"
pyapple shortcuts run "Start Pomodoro" --timeout 30

presence — 5

Operation Class What it does
snapshot read all four at once — the one to poll, and the only one that survives a partial failure
focus read which Focus mode is on. The only one that costs a grant
app read the frontmost application
idle read seconds since the last input
screen read locked, or a screensaver running

Every operation is a read and there is no write half. idle asks the event source how long ago the last event was, which is not an event tap and needs no Accessibility grant — that difference is the whole reason an idle clock is in scope here and a keystroke is not.

pyapple presence snapshot --json | jq '{focus: .focus.active, idle: .idle.seconds}'

doctor — 1

Operation Class What it does
report read what macOS is allowing, per tool, and where to click

pyapple perms reports the policy; doctor reports the machine. They are two independent gates and a caller that hits a wall cannot otherwise tell which one stopped it — mail=read with no Full Disk Access, and Full Disk Access under PYAPPLE_READ_ONLY=1, both produce "you cannot have that" and the remedies are in different applications. The report keeps the two answers in separate keys and never folds them together: a tool macOS has granted and the policy has removed is not unavailable, it is withheld, and sending someone to System Settings for it wastes their time.

Nothing here launches an app or can raise a permission prompt. That is why the obvious probes are not used. check_app_access sends get name to an app, which launches it and prompts for Automation on a machine that has never been asked — the diagnostic causing the thing it diagnoses — so AEDeterminePermissionToAutomateTarget with askUserIfNeeded=false is used instead (0.0058s against a running app, 0.0001s against one that is not). Accessibility is read with AXIsProcessTrusted() rather than by asking System Events, which would itself need an Automation grant.

Where answering would need a prompt or a launch, the finding is undetermined with its reason attached rather than reported as missing — "the grant is off" and "nobody can say" send a user to two different places. Full Disk Access is inferred from a canary (TCC.db, which exists on every Mac and opens only with FDA, opened and closed and never read) rather than from the store that failed, because macOS can report a TCC denial as a missing file and chat.db failing to open cannot otherwise distinguish a withheld grant from a Messages that has never run. Nothing is cached between calls: re-running it after you click in System Settings is the whole recheck story, and a cache would make it lie.

pyapple doctor report
pyapple doctor report --tool mail --json

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/attachments --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/attachments/send-attachment --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-before do 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 and mail open select by --subject, a substring match, and have no id form. Aliasing that to --id would invite an integer that silently substring-matches. mail reply and mail reply-draft do take --id as of 1.6.0, as a separate flag rather than an alias: it is the #N every search and unread row prints, and a subject that matches more than one message is now refused rather than resolved to the most recent.
  • 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 ofmessages 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, unwrappedmail 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.

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 <alex@example.com> - Thursday
pyapple mail reply-draft --id 102917 --body-file reply.md

--subject still works and is a substring match; when it matches more than one message the reply is refused, before anything is composed, and the candidates are printed with their ids.

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.

Long bodies, and formatted ones

On mail send, mail draft, mail reply, mail reply-draft, mail forward and mail forward-draft.

--body "$(cat draft.md)" was the only way to write a real email through this, and it is two problems: your shell re-expands what it interpolates, so a body containing a backtick or a $( runs as a command, and ARG_MAX caps how long the message can be. Both are now avoidable:

pyapple mail draft --to alex@example.com --subject "Thursday" --body-file reply.md
pyapple mail reply-draft --id 102917 --body - <<'EOF'
Works for me. I have put the figures in the attached.
EOF

--body-file - also reads standard input. --body and --body-file are mutually exclusive and passing both is exit 2: two sources for one value is a question, and answering it quietly is how the wrong text ends up in somebody's mail.

--html and --html-file compose a formatted body — bold, italics, sizes, links. There is no scripting route to rich text on macOS (html content is documented by Apple as doing nothing), so it goes on the pasteboard with a public.html flavour and is pasted into the compose window, which is why it needs the Accessibility grant. --body is its plain-text alternative and is derived from the markup if you leave it out; it is never optional underneath, because it is what a reader with images off sees.

pyapple mail draft --to alex@example.com --subject "Thursday" \
    --html '<p>Works for me — <b>10:30</b> in <a href="https://maps.apple.com">the usual place</a>.</p>'

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"
      }
    }
  }
}
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 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. 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 <dir>/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 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. 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 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:

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:

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.

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
│   ├── 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 file for details.

Acknowledgments

  • Inspired by the original apple-mcp TypeScript implementation
  • Built with FastMCP
  • Uses PyObjC for macOS system integration

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyapple_mcp-1.6.1.tar.gz (819.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyapple_mcp-1.6.1-py3-none-any.whl (450.6 kB view details)

Uploaded Python 3

File details

Details for the file pyapple_mcp-1.6.1.tar.gz.

File metadata

  • Download URL: pyapple_mcp-1.6.1.tar.gz
  • Upload date:
  • Size: 819.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyapple_mcp-1.6.1.tar.gz
Algorithm Hash digest
SHA256 d01bc60da19c47af00c29b54a3caacd38fd2ffa3fd29db66cb0b363d28d02f77
MD5 c3fd67739c1572672ce3eb05002395ef
BLAKE2b-256 db489ad5ce08e937a24821f88666b4b8942ab2cdb0a621b78d6c9c2ae8a6b682

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyapple_mcp-1.6.1.tar.gz:

Publisher: publish.yml on 54yyyu/pyapple-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyapple_mcp-1.6.1-py3-none-any.whl.

File metadata

  • Download URL: pyapple_mcp-1.6.1-py3-none-any.whl
  • Upload date:
  • Size: 450.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyapple_mcp-1.6.1-py3-none-any.whl
Algorithm Hash digest
SHA256 ef0715cfc5655b45490e9448ed7076c41e91f882af39e4f1afdda5b87618ccf1
MD5 a13eba666f853f98301957853976f2c2
BLAKE2b-256 c5421ccdb378b0a785790721f30bcbe1d70d148e2cca35fab66cb1b6274dc3fe

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyapple_mcp-1.6.1-py3-none-any.whl:

Publisher: publish.yml on 54yyyu/pyapple-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page