MCP server for Apple Notes on macOS — read, write, and search with full Markdown round-trip. Reads via SQLite (sub-100ms, concurrent with Notes.app), writes via AppleScript (preserves iCloud sync). Part of the -brain family.
Project description
apple-notes-brain
A Model Context Protocol server for Apple Notes on macOS. Gives Claude (or any MCP client) read, write, and search access to your notes — including searching inside note bodies, nested-folder scoping, full Markdown fidelity on reads, Markdown input on writes, and a set of ergonomics aimed at minimising token use and avoiding follow-up tool calls.
Part of the
-brainfamily of MCP servers. Sibling project:obsidian-brain— same idea for Obsidian vaults.
Why this exists
The default "just wrap AppleScript" approach either strips every piece of formatting (so headings, bullets, links, checklists, tables all vanish) or returns the raw HTML (which eats tokens). This server:
- Reads via SQLite (sub-100ms, concurrent with Notes.app) and writes via AppleScript (the only supported Apple API that preserves iCloud sync).
- Returns structured bodies as Markdown by default so LLMs get real
headings,
**bold**,*italic*,- [x]checklists,[text](url)links, fenced code blocks, tables, andplaceholders for embedded images. - Accepts Markdown input on
create_note/update_noteand converts to Apple's HTML dialect (<b>not<strong>,<ul class="checklist">, etc.) before assignment. - Uses short 4–6-char IDs (
p160) on the wire, with a resolver that still accepts the fullx-coredata://…/ICNote/pNNNURI. - Paginates with a
has_more/next_cursorenvelope so the LLM can tell when there's more to fetch. - Returns up to three non-overlapping snippet spans and a
match_countper search hit so the LLM can judge relevance without fetching the full body. - Respects locked notes: body never read,
locked: truesurfaced on every affected row, writes refused with a clear error.
Tools
| Tool | Kind | What it does |
|---|---|---|
list_folders(include_counts?) |
read | Every folder as a nested path with is_trash flag. Set include_counts: true for per-folder note counts. |
list_notes(folder_path?, limit?, cursor?, include_trash?, modified_after?, modified_before?) |
read | Paginated list, most-recent first. Default 20 rows, max 500 per page. Excludes Recently Deleted by default. Accepts ISO-8601 date or datetime bounds. |
search_notes(query, folder_path?, search_body?, fuzzy?, mode?, limit?, cursor?, include_body?, max_body_chars?, include_trash?, modified_after?, modified_before?) |
read | Search across titles and body plaintext. Returns up to 3 snippet spans + match_count per hit. mode is substring (default) or regex. include_body=true bundles up to max_body_chars (≤2000) of text on the TOP 5 results. |
get_note(note_id, format?, fast?) |
read | Full note. format is markdown (default), text, or html. fast=true uses a lossy SQLite reader (text only, sub-100ms, no formatting). |
get_notes(note_ids, format?, fast?) |
read | Batch fetch up to 20 notes. Parallel AppleScript fan-out by default. Locked notes come back with a sentinel body; the batch does not fail. |
create_note(title, body, folder_path?, format?) |
write | Create a note. format is markdown (default), html, or text. |
update_note(note_id, body, append?, format?, allow_attachment_loss?) |
write | Replace or append to the end of the body. Refuses notes with attachments unless allow_attachment_loss=true (Apple bug — set body destroys attachments). Refuses locked notes. Does not rename — use rename_note. Does not move — use move_note. |
rename_note(note_id, new_title) |
write | Rename one or many notes. note_id + new_title can both be strings (single) OR both lists of equal length (batch, up to 20). Body untouched. |
move_note(note_id, folder_path) |
write | Move one or many notes. note_id can be a string (single) or a list (batch, up to 20); folder_path is always a single destination. Body untouched; refuses moves into Recently Deleted. |
create_folder(name, parent_folder_path?) |
write | Create a folder, optionally nested under a parent. Name cannot contain /. |
delete_note(note_id) |
destructive | Move to Recently Deleted, or permanently delete if already there. Refuses locked notes. |
All write tools return {id, action, error?} (action ∈ created/updated/renamed/moved/deleted/skipped). All read tools return typed Pydantic models so FastMCP emits a proper outputSchema.
Batch semantics (rename_note, move_note)
- Single-note call: raises on any failure (locked note, missing note, missing folder, etc.). Returns one
MutationResult. - Batch call: validation errors (missing folder, too many notes, shape mismatch) raise up-front before any AppleScript runs. Once the batch starts, per-note failures come back as
MutationResult(id, action="skipped", error="…")instead of killing the batch — mirrors howget_noteshandles locked notes. The batch fans out over 5 concurrent AppleScript workers. - Max 20 notes per batch. Empty list → empty list (no-op).
macOS permission prompt (writes only)
The first write-path tool call per session may trigger an OS-level Automation permission dialog for the process running the server (typically Claude Desktop). If the user doesn't see or doesn't approve the prompt, osascript hangs until the 60-second timeout. Approve once and it's sticky for that process.
Resources
On clients that support MCP resources (Claude Desktop, Claude Code, Cursor, Continue):
notes://note/{id}→ note body as Markdown (default).notes://note/{id}/html→ raw HTML.notes://note/{id}/text→ plaintext.
On server startup the 50 most-recently-modified notes are also registered as
individual notes://recent/{id} resources so they appear in @-mention
autocomplete. Locked notes are omitted from the autocomplete list. Clients
that don't speak resources ignore all of this and use the get_note tool
instead — capability negotiation handles it automatically.
Prompt
notes_server_overview — a single built-in prompt that returns a short
architecture summary (SQLite for reads, AppleScript for writes, ID format,
locked-note semantics, pagination envelope, FTS availability on this
machine). Useful as a one-shot context-primer.
How it actually works
- SQLite path (
sqlite_reader.py) opens~/Library/Group Containers/group.com.apple.notes/NoteStore.sqliteread-only viafile:…?mode=ro. Apple Notes uses WAL mode, so our reads never block Notes.app's writes and vice versa. Requires Full Disk Access for the process running the server. - AppleScript path (
applescript.py+scripts.py) shells out toosascriptwithcharacter id 30/31record/unit separators to parse output unambiguously. Values are escaped viaapplescript.quote()before substitution — injection-safe. Requires Automation permission for the process running the server. - Folder nesting is resolved by walking
ZPARENTinZICCLOUDSYNCINGOBJECT. A scoped call likefolder_path="Work"matches that folder and every descendant. - Note bodies are stored as gzip-compressed protobuf in
ZICNOTEDATA.ZDATA. Apple doesn't publish the schema. For search, the server decompresses and walks the protobuf extracting UTF-8 string fields (lossy but fine for substring matching). For rendered output, AppleScript'sbody of noteproperty returns proper HTML which the server converts to Markdown viamarkdownify+ Apple-specific pre-processing (checklists, attachment stubs, monospaced blocks). - FTS — the code probes for a SQLite FTS shadow table at startup. If Apple ships one on your macOS version it will be used for search. Otherwise the server falls back to the decompress-and-scan loop.
Markdown round-trip
Read direction (HTML → Markdown):
- Headings
<h1>/<h2>/<h3>→#/##/### - Bold/italic/strike (
<b>,<i>,<strike>) →**/*/~~ - Lists, numbered lists, nested lists
- Checklists
<ul class="checklist"><li class="checked">→- [x]/- [ ] - Links
<a href>→[text](url) - Fenced code
<pre><code class="language-py">→```py ... ``` - Attachments
<object id="...">→placeholder (binary lives separately at~/Library/Group Containers/group.com.apple.notes/Accounts/.../Media/— not returned inline) - Tables → standard Markdown tables
Write direction (Markdown → Apple HTML):
- Inverse of the above, using Apple's preferred tags (
<b>not<strong>,<div>not<p>). h4+ are downgraded to plain<div>(Apple Notes only renders h1–h3).- [x]/- [ ]→<ul class="checklist"><li class="checked">.
⚠️ Attachment-destructive writes
Apple's AppleScript set body of note has a known, silent bug: it deletes
every attachment on the target note (images, sketches, scans, file
attachments) with no warning from Apple. update_note guards against this:
- Before writing, the server queries the attachment count for the target note via SQLite.
- If the count is > 0 and
allow_attachment_loss=False(the default), the write is refused with a clear error naming how many attachments would be lost. - An LLM / client can only override by passing
allow_attachment_loss=True— which should only happen after explicit user confirmation.
create_note is unaffected (new notes have no attachments). delete_note
is a deliberate deletion — no guard needed.
The attachments count is also returned on every get_note / get_notes
response so the LLM can surface it without a separate query.
Locked notes
Apple Notes' password-protected notes encrypt the body blob. This server never decrypts and never peeks:
search_notesmatches locked notes by title only — the body is never decompressed. Matched rows come back withlocked: trueand a synthetic snippet of[locked — title matched; body encrypted].list_notessurfaces locked notes withlocked: trueand an empty body preview.get_noteon a locked note short-circuits before AppleScript and returns a body of[locked — unlock this note in Notes.app to read its contents]pluslocked: true.update_noteanddelete_noteon a locked note raise an error rather than failing mid-AppleScript.- Locked notes are omitted from the
notes://recent/…autocomplete list.
Install
Requires macOS, Python 3.11+, and Apple Notes.
One-line macOS installer (recommended)
Handles uv install, the /usr/local/bin PATH symlinks Claude Desktop needs,
the Claude Desktop config merge (preserves your other MCP servers), the
Full Disk Access walkthrough for both Claude.app and the uv-managed
Python (the cached-Python TCC quirk that breaks SQLite reads — see Known
issue below),
and the Automation prompt heads-up.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sweir1/apple-notes-brain/main/scripts/install.sh)"
Idempotent — safe to re-run if you change your mind, hit a TCC mismatch, or want to update.
Quickest — uvx (no install)
uvx apple-notes-brain
Runs the server in an ephemeral environment. Best for trying it out or for client config that should always pull the latest.
Tool install — uv
uv tool install apple-notes-brain
Installs the apple-notes-brain command globally (managed by uv). Update
later with uv tool upgrade apple-notes-brain.
Classic — pip
pip install apple-notes-brain
From source
git clone https://github.com/sweir1/apple-notes-brain.git
cd apple-notes-brain
uv sync # or: python3.11 -m venv .venv && .venv/bin/pip install -e .
Hook up to Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"apple-notes-brain": {
"command": "uvx",
"args": [
"apple-notes-brain"
]
}
}
}
Or, if you uv tool install-ed it:
{
"mcpServers": {
"apple-notes-brain": {
"command": "apple-notes-brain"
}
}
}
Restart Claude Desktop. On first use macOS will prompt for:
- Full Disk Access for the parent process (needed to read NoteStore.sqlite).
- Automation permission to control Notes (needed for full body reads and all writes).
Grant both. The Automation prompt is sticky once approved; Full Disk Access is granted in System Settings → Privacy & Security → Full Disk Access.
Known issue: Full Disk Access + uvx-managed Python on macOS
If after install Claude Desktop's logs show:
could not pre-populate recent notes: cannot open NoteStore (unable to open database file).
apple-notes-brain registered 0 recent notes as resources
…and SQLite-backed tools (list_folders, search_notes, list_notes) return
empty results — but AppleScript-backed tools (create_note, delete_note)
work — then macOS Full Disk Access has been granted to Claude Desktop but is
not propagating to the uv-managed Python interpreter that uvx spawns.
This is a known macOS TCC quirk: when uvx runs, it executes a Python binary
from ~/.local/share/uv/python/cpython-X.Y.Z-…/bin/python (a Python that uv
downloaded from python-build-standalone). macOS treats this as a separately
attributable binary and doesn't always honor inherited FDA from the parent
process.
Fix: grant Full Disk Access to the uv-managed Python directly.
- Find the active path:
uv python find
Output looks like/Users/<you>/.local/share/uv/python/cpython-3.13.X-macos-aarch64-none/bin/python. - Open System Settings → Privacy & Security → Full Disk Access.
- Click
+, pressCmd+Shift+G, paste the path from step 1, hit return, select thepythonbinary, click Open, and toggle it on. - Quit and relaunch Claude Desktop.
The path may change when uv updates the Python interpreter (e.g. after
uv self update or a major version bump). If SQLite reads stop working
after a uv update, redo the FDA grant for the new path.
For new users on macOS, the install script handles this walkthrough automatically.
Other MCP clients
Same idea — point the client's MCP server config at uvx apple-notes-brain
or the installed apple-notes-brain binary. Tested with Claude Desktop,
Claude Code, Cursor, and Continue.
Example: search → fetch
search_notes(query="butter chicken", limit=3)
→ {
"results": [
{"id": "p160", "title": "For butter chicken", "folder": "Notes",
"modified": "2026-04-09 11:12", "match_count": 6,
"snippets": ["…big ass pot melt butter 2) add…", "…150-200g organic butter (to cook…", "…peanut butter 🔴 Rewe?…"],
"pinned": false, "locked": false},
...
],
"returned": 3, "has_more": false, "next_cursor": null, "total_estimate": null
}
get_note("p160") # note the short id
→ NoteDetail(id="p160", title="For butter chicken", format="markdown",
body="## For butter chicken\n\n2-4 people …",
pinned=false, locked=false, …)
Cache coherence — when counts look stale
This server reads from SQLite directly and writes through Notes.app via AppleScript. The two are eventually consistent, not instantaneously consistent, and most counts / folder FKs you see at the start of a new session may be a few hundred milliseconds to a few minutes behind reality.
Three distinct classes of staleness:
- Zombie trash rows — notes auto-purged by Apple's 30-day rule remain as rows in SQLite until the app next compacts. They carry
ZFOLDERTYPE=1but are already gone in the live app. Harmless for reads (their folder isRecently Deleted); confusing when counting. - Stale
ZFOLDERFKs — a note moved in the app may still show its old folder in SQLite until the app persists the move. This is the "why is p269 in BuildProtect?" case. - Post-write read lag — after an AppleScript write returns, the next SQLite read may still show the old state for a brief window (usually < 1 second).
Mitigations built in (fully automatic — nothing to call manually):
- Startup pre-warm: at server startup,
cache.prewarm()runs a no-op AppleScript ping. This (a) triggers the macOS Automation permission prompt BEFORE any user-invoked tool call, and (b) wakes Notes.app so it flushes its in-memory state. Takes < 200ms when permission is already granted. - Post-write sync: every write call (
create_note,update_note,rename_note,move_note,delete_note, folder ops) auto-flushes viacache.sync_after_write()— the next SQLite read sees the change. - Background auto-refresh: a daemon thread pings Notes.app every 4 seconds while you're actively using MCP tools. Catches changes made outside the MCP (user edits in Notes.app directly, iCloud sync from another device). Layered cost controls:
- Idle pause: if no MCP tool has been called for 5 minutes, ticks stop until activity resumes. The next tool call instantly wakes the refresher.
- Notes.app closed skip: when Notes.app isn't running, ticks short-circuit (~5ms
pgrepcheck, no AppleScript invocation, no auto-launch). CloudKit daemons still flow iCloud changes into SQLite independently, so reads stay fresh without needing our ping. - System sleep freeze: lid close / true sleep freezes the thread (
CLOCK_UPTIME_RAWdoesn't advance during sleep). Resumes within one interval after wake. Zero cost during sleep. - Client-gated lifecycle: thread only exists while the MCP client (Claude Desktop, etc.) is connected. Quit the client → process dies → thread dies.
- Cost in the busiest state (Claude active + Notes.app open + chatting): ~15 ticks/min × ~100ms = 2.5% of one core ≈ 0.25% system-wide. Everything else is cheaper.
Environment knobs:
NOTES_MCP_AUTO_REFRESH=0— disable the background thread entirely.NOTES_MCP_REFRESH_INTERVAL=10— cadence in seconds (default 4, min 1).NOTES_MCP_IDLE_THRESHOLD=600— idle pause threshold in seconds (default 300 = 5 min; set to 0 to disable idle pausing).
Apple provides no formal flush API, so worst-case staleness is one background tick interval (~4s by default) for changes made outside the MCP during an active session. MCP-initiated changes are always immediately visible.
Known limitations
- FTS: not enabled unless macOS ships a compatible shadow table on your install. The code probes at startup and logs availability.
- Attachments (image binaries, sketches, scans) are surfaced as placeholder Markdown — binary fetching is not implemented yet.
- Account info (iCloud vs On My Mac vs Gmail) is not exposed per-note yet.
- Tags (
#tagsyntax) are not separately surfaced — they appear as plain text in the body. pin_noteis not implemented. Apple's AppleScript surface for Notes doesn't expose thepinnedproperty as settable. Reading the current state (pinnedfield onNoteSummary/NoteDetail) works via SQLite; flipping it requires writing toZISPINNEDdirectly, which would desync iCloud — deliberately out of scope.bulk_deleteis not a separate tool; loopdelete_note. Each call is a cheap AppleScript dispatch.get_attachments(fetching image/sketch binaries from the Media directory) is deferred.- Checklist tick state on writes: reliable only when Apple Notes' HTML
includes
class="checked"on the<li>. On some macOS versions the class is stripped on read — your- [x]input is always correctly encoded on write, but round-tripping may lose the tick. - Cache staleness at session start: ZFOLDER FKs and zombie trash rows may show wrong state briefly until the pre-warm AppleScript ping flushes Notes.app. The background auto-refresh (10s default) catches this automatically.
Development
git clone https://github.com/sweir1/apple-notes-brain.git
cd apple-notes-brain
uv sync
Smoke-test the server boots:
uv run python -m notes_mcp < /dev/null
Run the MCP inspector:
uv run --with 'mcp[cli]' mcp dev src/notes_mcp/server.py
Run the test suite (unit tests only — live tests are opt-in via -m live
and require Notes.app + iCloud):
uv run pytest
Layout
src/notes_mcp/
server.py # FastMCP registration: tools, resources, prompt, annotations
tools.py # Tool implementations (framework-free, Pydantic returns)
schemas.py # Pydantic output models
sqlite_reader.py # Read-only SQLite access, short IDs, cursor pagination, FTS probe
applescript.py # osascript subprocess runner + quoting + record parsing
scripts.py # AppleScript templates
html_text.py # HTML → plaintext, multi-span snippets, match counter
markdown.py # HTML ↔ Markdown (both directions, Apple-flavoured)
search.py # Token ranking + substring/phrase/regex matchers
protobuf_reader.py # ZMERGEABLEDATA1 decoder for checklist state recovery
proto/ # Apple Notes protobuf schema (vendored, MIT)
License
Apache License 2.0 — Copyright 2026 sweir1.
Related projects
obsidian-brain— sibling MCP server for Obsidian vaults: semantic search, knowledge graph, vault editing.modelcontextprotocol/python-sdk— the official MCP Python SDK this server is built on (provides theFastMCPclass atmcp.server.fastmcp).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file apple_notes_brain-1.0.3.tar.gz.
File metadata
- Download URL: apple_notes_brain-1.0.3.tar.gz
- Upload date:
- Size: 77.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
96a7f103321c49257cf162d8540597a127ffba16bbcd68a7d815b18acea59f36
|
|
| MD5 |
0fa126892ee79f78c1f4f910883a04a5
|
|
| BLAKE2b-256 |
1ee6aa1077760f027d7b7663ed3a0a56b54b68d6d20a86bcad3f57b74f02378f
|
Provenance
The following attestation bundles were made for apple_notes_brain-1.0.3.tar.gz:
Publisher:
release.yml on sweir1/apple-notes-brain
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
apple_notes_brain-1.0.3.tar.gz -
Subject digest:
96a7f103321c49257cf162d8540597a127ffba16bbcd68a7d815b18acea59f36 - Sigstore transparency entry: 1391664454
- Sigstore integration time:
-
Permalink:
sweir1/apple-notes-brain@e6be03156dac6c2a21979c11546dbb61388ef8e7 -
Branch / Tag:
refs/tags/v1.0.3 - Owner: https://github.com/sweir1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e6be03156dac6c2a21979c11546dbb61388ef8e7 -
Trigger Event:
push
-
Statement type:
File details
Details for the file apple_notes_brain-1.0.3-py3-none-any.whl.
File metadata
- Download URL: apple_notes_brain-1.0.3-py3-none-any.whl
- Upload date:
- Size: 83.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a90f512a9a049c3ebb2a5c4486697605bb27483314fcd5f63606d1e8d75e266b
|
|
| MD5 |
8563c85f03acef3b617514c0ad43ecac
|
|
| BLAKE2b-256 |
7b854cf3e3f13eb5d9aea859bc0cce89236806abfa72e35aaddfe9bc3f8c475e
|
Provenance
The following attestation bundles were made for apple_notes_brain-1.0.3-py3-none-any.whl:
Publisher:
release.yml on sweir1/apple-notes-brain
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
apple_notes_brain-1.0.3-py3-none-any.whl -
Subject digest:
a90f512a9a049c3ebb2a5c4486697605bb27483314fcd5f63606d1e8d75e266b - Sigstore transparency entry: 1391664513
- Sigstore integration time:
-
Permalink:
sweir1/apple-notes-brain@e6be03156dac6c2a21979c11546dbb61388ef8e7 -
Branch / Tag:
refs/tags/v1.0.3 - Owner: https://github.com/sweir1
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e6be03156dac6c2a21979c11546dbb61388ef8e7 -
Trigger Event:
push
-
Statement type: