# Apple Notes MCP Server

A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables AI assistants like Claude to read, create, search, and manage notes in Apple Notes on macOS.

[![npm version](https://img.shields.io/npm/v/apple-notes-mcp)](https://www.npmjs.com/package/apple-notes-mcp)
[![npm downloads](https://img.shields.io/npm/dm/apple-notes-mcp)](https://www.npmjs.com/package/apple-notes-mcp)
[![node](https://img.shields.io/node/v/apple-notes-mcp)](https://www.npmjs.com/package/apple-notes-mcp)
[![CI](https://github.com/sweetrb/apple-notes-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/sweetrb/apple-notes-mcp/actions/workflows/ci.yml)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/sweetrb/apple-notes-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/sweetrb/apple-notes-mcp)
[![platform: macOS](https://img.shields.io/badge/platform-macOS-111?logo=apple&logoColor=white)](https://www.apple.com/macos/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![MCP](https://img.shields.io/badge/MCP-server-blue)](https://modelcontextprotocol.io)

<p align="center">
  <img src="https://raw.githubusercontent.com/sweetrb/apple-notes-mcp/main/codex/assets/screenshot.png" alt="Apple Notes MCP — create, search, and organize Apple Notes from Codex, Claude, and other AI assistants" width="680">
</p>

## What is This?

This server acts as a bridge between AI assistants and Apple Notes. Once configured, you can ask Claude (or any MCP-compatible AI) to:

- "Save this conversation as a note called 'Meeting Summary'"
- "Find all my notes about the project deadline"
- "Read my shopping list note"
- "Move my draft notes to the Archive folder"
- "What notes do I have in my Work folder?"

The AI assistant communicates with this server, which then uses AppleScript to interact with the Notes app on your Mac. All data stays local on your machine.

## Quick Start

### Using Claude Code (Easiest)

If you're using [Claude Code](https://claude.com/product/claude-code) (in Terminal or VS Code), just ask Claude to install it:

```
Install the sweetrb/apple-notes-mcp MCP server so you can help me manage my Apple Notes
```

Claude will handle the installation and configuration automatically.

Or register it yourself with one deterministic command:

```bash
claude mcp add apple-notes -s user -- npx -y apple-notes-mcp
```

### Using the Plugin Marketplace

Install as a Claude Code plugin for automatic configuration and enhanced AI behavior:

```bash
/plugin marketplace add sweetrb/apple-notes-mcp
/plugin install apple-notes
```

This method also installs a **skill** that teaches Claude when and how to use Apple Notes effectively.

On the first tool call, macOS shows an Automation permission prompt ("Claude" wants access to control "Notes") — click **OK**. Optionally, grant **Full Disk Access** to the app that launches the server to enable the database-backed tools (`get-checklist-state`, `get-note-metadata`, `get-note-link`, checklist annotations in `get-note-markdown`, and full `get-sync-status` detail); see the [Full Disk Access Setup Guide](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md). The rest of the server is pure AppleScript and works without it.

Native tag, checklist, table, pin, and rich append operations use two packaged
Apple Shortcuts. A third, `Apple Notes MCP - Create Markdown Note`, is optional:
only `create-note`'s `format: "markdown"` needs it, and only on macOS 26 or
later. Run the explicit setup once:

```bash
npx -y apple-notes-mcp setup
```

The command checks existing installations and opens only missing signed
workflows; it opens the optional Create Markdown Note bridge only on macOS 26 or
later. Confirm **Add Shortcut** in each macOS window, then verify with
`npx -y apple-notes-mcp setup --check` or the MCP `doctor` tool. `setup --check`
reports ready and `doctor` reports ok once the two required bridges are
installed; both list the optional bridge's status separately. macOS does not
support silent Shortcut import, so merely connecting an MCP client never opens
setup windows or bypasses these confirmations.

After install **and after every upgrade**, open Shortcuts.app and run each
installed bridge — `Apple Notes MCP - Native Tags`,
`Apple Notes MCP - Background Operations v5` and, if you installed it, the
optional `Apple Notes MCP - Create Markdown Note` — once in the foreground, choosing
**Always Allow** when Shortcuts asks for permission. The Create Markdown Note
bridge stops before reaching Notes when run with no input, so start its run
with the request in [`shortcuts/README.md`](shortcuts/README.md). The server runs these
Shortcuts in the background, where Shortcuts cannot display a first-run consent
prompt: an unanswered one stalls every native write on that bridge until it
times out, while `doctor` still reports the bridge installed. Quitting or
relaunching Shortcuts.app or Notes.app does not clear it; the foreground run
does, once per bridge.

### Using the Codex Marketplace

The same plugin is available for Codex. Add the marketplace and install the plugin:

```bash
codex plugin marketplace add sweetrb/apple-notes-mcp
codex plugin add apple-notes@apple-notes-mcp
```

The Codex plugin runs the published `apple-notes-mcp` server through `npx` and ships the same Apple Notes skill, so behavior matches the Claude Code plugin.

### Other Hosts (Hermes, Antigravity)

Two more hosts can run the same `apple-notes` MCP server (`npx -y apple-notes-mcp`):

- **[Hermes Agent](https://hermes-agent.nousresearch.com/)** (NousResearch) — Hermes has no plugin/marketplace drop-in, so there is nothing in this repo to install from. Register the server with the CLI:

  ```bash
  hermes mcp add apple-notes --command npx --args -y apple-notes-mcp
  ```

  Or add it to `~/.hermes/config.yaml` by hand:

  ```yaml
  mcp_servers:
    apple-notes:
      command: npx
      args: ["-y", "apple-notes-mcp"]
  ```

  Restart your Hermes session afterward so the tools load.
- **[Antigravity](https://antigravity.google/)** (Google) — add the server entry from [`.antigravity-plugin/mcp_config.json`](https://github.com/sweetrb/apple-notes-mcp/blob/main/.antigravity-plugin/mcp_config.json) to `~/.gemini/config/mcp_config.json` (or via Antigravity's MCP settings).

### Using Claude Desktop

**1. Install the server:**
```bash
npm install -g apple-notes-mcp
```

**2. Add to Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
  "mcpServers": {
    "apple-notes": {
      "command": "npx",
      "args": ["-y", "apple-notes-mcp"]
    }
  }
}
```

**3. Restart Claude Desktop** and start using natural language:
```
"Create a note called 'Ideas' with my brainstorming thoughts"
```

On first use, macOS will ask for permission to automate Notes.app. Click "OK" to allow.

## Requirements

- **macOS** - Apple Notes and AppleScript are macOS-only
- **Node.js 20+** - Required for the MCP server
- **Apple Notes** - Must have at least one account configured (iCloud, Gmail, etc.)

## Features

| Feature | Description |
|---------|-------------|
| **Create Notes** | Create notes with titles, content, and optional folder/account targeting |
| **Search Notes** | Find notes by title or search within note content |
| **Read Notes** | Retrieve note content and metadata |
| **Update Notes** | Modify existing notes (title and/or content) |
| **Delete Notes** | Remove notes (moves to Recently Deleted) |
| **Move Notes** | Organize notes into folders (supports nested paths) |
| **Folder Management** | Create, list, and delete folders with full hierarchical path support |
| **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account, including account IDs and default folders |
| **Batch Operations** | Delete or move multiple notes at once |
| **Checklist State** | Read checklist done/undone state directly from the Notes database (requires Full Disk Access) |
| **Export** | Export all notes as JSON or get individual notes as Markdown |
| **Attachments** | List attachments, save them to disk, or fetch their bytes as base64 |
| **Notes.app UI State** | Reveal a note in Notes.app or read the current Notes.app selection |
| **Sync Awareness** | Detect iCloud sync in progress, warn about incomplete results |
| **Collaboration** | Detect shared notes, warn before modifying |
| **Diagnostics** | `health-check` plus a richer `doctor` (reachability, automation permission, accounts, Full Disk Access), sync status, and statistics |

Read/list/get tools also return **structured JSON** (`structuredContent`) alongside the text, so agents can consume results without parsing prose.

### MCP resources & prompts

Resources expose read-only context the client can attach without a tool call:
`notes://accounts`, `notes://folders`, `notes://stats`, and the
`notes://note/{id}` template (returns the note as Markdown). Prompts package
common workflows: `find-note`, `weekly-review`, `new-meeting-note`.

### AppleScript limitations

A few Notes UI features are not exposed to AppleScript. Some are recovered by
reading Notes' own database instead; the rest genuinely cannot be supported. See
**[docs/APPLESCRIPT-LIMITATIONS.md](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/APPLESCRIPT-LIMITATIONS.md)**
for the investigation and verification behind each:

- **Pinned notes** — Notes has no scriptable `pinned` property via AppleScript. Pin state can now be **read** with the BETA `get-note-metadata` tool (from the NoteStore database), but it still cannot be **set** programmatically.
- **Note-to-note links** — AppleScript exposes no link property or link element, so link *relationships* between notes cannot be read, and a link cannot be inserted into a note body. A shareable `notes://showNote?identifier=<uuid>` deep link **is** available via [`get-note-link`](#get-note-link).

---

## Tool Reference

This section documents all available tools. AI agents should use these tool names and parameters exactly as specified.

### Note Operations

#### `create-note`

Creates a new note in Apple Notes.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `title` | string | Yes | The title of the note. Automatically prepended as `<h1>` — do NOT include the title in `content` |
| `content` | string | Yes | The body content of the note (do not repeat the title here) |
| `tags` | string[] | No | Returned-only metadata — **NOT written to Notes.app**. Apple Notes tags can't be set via AppleScript, so values passed here are echoed back in the response but do not appear on the created note. Use inline `#hashtags` in `content` instead (Notes.app turns those into real tags). Refused with `format: "markdown"` |
| `folder` | string | No | Folder to create the note in. Supports nested paths like `"Work/Clients"`. **The folder must already exist** — create it first with [`create-folder`](#create-folder). Defaults to account root |
| `account` | string | No | Account name (defaults to Notes.app's default account; matched exactly or by a *unique* prefix — an ambiguous prefix is refused). Must be an account Notes.app already has configured — see [`list-accounts`](#list-accounts) |
| `format` | string | No | Content format: `"plaintext"` (default), `"html"`, or `"markdown"`. In all formats, the title is automatically prepended as the note's title line. In plaintext mode, newlines become `<br>`, tabs become `<br>`, and backslashes are preserved as HTML entities. `"markdown"` produces real Title/Heading/Subheading styles through a Shortcut; see [Markdown notes](#markdown-notes) |

**Example (tagged with inline hashtags):**
```json
{
  "title": "Meeting Notes",
  "content": "Discussed Q4 roadmap and budget allocation\n\n#work #meetings"
}
```

**Example - Create in a specific folder:**
```json
{
  "title": "Client Meeting",
  "content": "Discussed project timeline",
  "folder": "Work/Clients"
}
```

**Example - HTML formatting:**
```json
{
  "title": "Status Report",
  "content": "<h2>Summary</h2><p>All tasks <b>on track</b>.</p><ul><li>Feature A: complete</li><li>Feature B: in progress</li></ul>",
  "format": "html"
}
```

> **Note:** The title is automatically prepended as `<h1>` in both plaintext and HTML formats. Do not include a `<h1>` title tag in the `content` parameter, or the title will appear twice.

> **Known limitation:** with `"plaintext"` or `"html"`, `create-note` sets the note
> body directly via AppleScript's `body` property, which does not apply real Notes
> paragraph styles for interior content — an `<h2>`/`<h3>` tag or a
> `<span style="font-size: …px">` heading span in `content` renders as plain bold,
> styled text, not an actual Heading or Subheading
> ([#172](https://github.com/sweetrb/apple-notes-mcp/issues/172)). Use
> `format: "markdown"` for real headings in a new note, or
> [`append-native`](#append-native)'s `format: "markdown"` on a note that already
> exists.

##### Markdown notes

`format: "markdown"` creates the note with Notes' own Markdown importer (the
Create Note action's "Interpret as Markdown" option, macOS 26+), run through the
packaged `Apple Notes MCP - Create Markdown Note` Shortcut. `#`, `##` and `###`
become real Title, Heading and Subheading styles, and the note starts with the
title line, with no seed line.

```json
{
  "title": "Project Plan",
  "content": "## Goals\n\n- Ship the beta\n- Collect feedback\n\n### Links\n\n[Tracker](https://example.com/tracker)",
  "format": "markdown",
  "folder": "Work"
}
```

- Notes interprets Markdown only in an iCloud account. The note is created in the
  iCloud account's default folder, then moved to `folder` in that account.
  `folder` must already exist and is checked before anything is created. If it
  exists only in another account, the note is still created and verified in the
  iCloud default folder, and the error names that account and the note's id so
  you can create the folder there and `move-note` it instead of creating the
  note again. `account` is refused with this format.
- `tags` are refused with this format. Create the note without them, then add
  native tags to the returned id with [`add-native-tags`](#add-native-tags).
- `content` accepts the same bounded subset as `append-native`'s Markdown:
  `#`/`##`/`###` headings, flat lists, `**bold**`, `*italic*` and inline links.
  It also refuses Markdown that Notes would rewrite and the server could not
  verify: `_` emphasis (underscores inside a word, as in `snake_case`, and
  inside a link destination, as in `[docs](https://example.com/_next/static)`,
  are fine), backslash escapes, character references such as `&amp;`, `---` or
  `===` lines, indented headings or list items, `1)` lists, closing `#`s, and
  formatting inside link labels. Content that needs one of these literally, such
  as a `/_next` path or a literal `\*`, has no Markdown form here: use
  `format: "html"` for that note (or `append-native` with `format: "html"` on an
  existing one), which keeps the characters but not the Heading and Subheading
  styles. Markdown punctuation in `title` is escaped, so the title stays literal.
- The server finds the new note among the notes added to the default folder
  during the run by verifying each one's visible text, heading levels and links
  by exact-ID readback, and moves it only after exactly one verifies. On any
  uncertain result it names the note (or says to search for the title) and
  never retries.
- It is gated like the other native operations; `get-capabilities` reports it
  as `create-note-markdown`. The Create Markdown Note Shortcut is optional and
  needed only for this format (macOS 26+); install and approve it as described in
  [`shortcuts/README.md`](shortcuts/README.md).

**Returns:** Confirmation message with note title and ID. Save the ID for subsequent operations like `update-note`, `delete-note`, etc.

---

#### `search-notes`

Searches for notes by title or content.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | Text to search for |
| `searchContent` | boolean | No | If `true`, searches note body; if `false` (default), searches titles only |
| `account` | string | No | Account to search in (defaults to Notes.app's default account; exact or unique-prefix match) |
| `folder` | string | No | Limit search to a specific folder (supports nested paths like `"Work/Clients"`) |
| `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
| `limit` | number | No | Maximum number of results to return. **Defaults to 50** — a broad query reads several properties per match via AppleScript (~200ms/note), so an unbounded search over hundreds of matches can exceed Notes' 30s timeout and return an error instead of results. Pass a higher value to see more; the applied limit (and whether it truncated the results) is disclosed in the response. |

**Example - Search titles:**
```json
{
  "query": "meeting"
}
```

**Example - Search content:**
```json
{
  "query": "budget allocation",
  "searchContent": true
}
```

**Example - Search recent notes with limit:**
```json
{
  "query": "todo",
  "searchContent": true,
  "modifiedSince": "2025-01-01",
  "limit": 10
}
```

**Returns:** List of matching notes with titles, folder names, and IDs. Use the returned ID for subsequent operations like `get-note-content`, `update-note`, etc.

---

#### `get-note-content`

Retrieves the full content of a specific note.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | No | Note ID (preferred - more reliable than title) |
| `title` | string | No | Note title (use `id` instead when available) |
| `account` | string | No | Account containing the note (defaults to Notes.app's default account; exact or unique-prefix match, ignored if `id` is provided) |

**Note:** Either `id` or `title` must be provided. Using `id` is recommended as it's unique and avoids issues with duplicate titles.

**Example - Using ID (recommended):**
```json
{
  "id": "x-coredata://ABC123/ICNote/p456"
}
```

**Example - Using title:**
```json
{
  "title": "Shopping List"
}
```

**Returns:** The HTML content of the note, its exact `id`, and a
`contentHash`. Pass that hash back as `expectedContentHash` for a later update,
append, or delete; the write is rejected if the note's body or rich metadata
changed after this read. With Full Disk Access, embedded URLs omitted by
AppleScript are restored and returned in `links`. The response also reports
actual `nativeTags`, `richContentComplete`, and `writable`. Textual `hashtags`
remain a separate field and are not proof that Notes registered native tags.

**⚠️ The returned body can be lossy — do not write it back verbatim.** Inline
base64 images larger than `APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES` (default
256 KB) are replaced with `[inline image omitted: …]` text placeholders so an
image-heavy note cannot blow the MCP message limit. `structuredContent` reports
this as `strippedImages` (count) and `truncated` (boolean). When either is set,
passing this body to an unguarded full-body writer would replace the real images
with placeholder text. This server refuses update and append operations on
attachment-bearing notes; edit them in Notes.app.

---

#### `get-native-objects`

Reads native object identities and ranges, checklist item IDs and state, actual
native tags, and native table data from one exact note ID. Table output includes
stable row and column identifiers. `tableCellsComplete` is false when Notes
metadata cannot be decoded completely. This tool is read-only and requires Full
Disk Access.

---

#### `list-native-tags`

Lists actual native Notes tags used within one explicit `account` and `folder`,
mapping each tag to its matching note IDs. This differs from textual hashtag
search. The response reports `complete: false` and per-note errors when some
native metadata is unavailable. This tool is read-only and requires Full Disk
Access.

---

#### `native-tags-status`

Checks whether exactly one configured Native Tags Shortcut is installed. An
installed workflow may still need macOS permission on its first execution, and
installation does not show whether that consent was given: run it once in the
foreground in Shortcuts.app and choose **Always Allow** after install or upgrade
(see [Troubleshooting](#native-writes-time-out-or-report-an-uncertain-outcome)).

---

#### `add-native-tags`

Adds actual native Notes tag objects to one exact note using `id`, a fresh
`expectedContentHash`, a distinctive existing `scopeText`, and `tags`. The
operation verifies the note's original text, links, and native objects after the
Shortcut runs. It refuses ambiguous title-and-scope matches and never retries an
uncertain write. Install the signed workflow as described in
[`shortcuts/README.md`](shortcuts/README.md).

---

#### `get-note-plaintext`

Retrieves a note's body as plain text, with no HTML markup.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | No | Note ID (preferred - more reliable than title) |
| `title` | string | No | Note title (use `id` instead when available) |
| `account` | string | No | Account containing the note (defaults to Notes.app's default account; exact or unique-prefix match, ignored if `id` is provided) |

**Note:** Either `id` or `title` must be provided. This reads the note's native `plaintext` property, so it skips the HTML-to-text conversion that `get-note-content` plus a Markdown pass would do. Use `get-note-content` when you need the HTML, or `get-note-markdown` when you want Markdown with checklist state.

**Returns:** The plain-text content of the note in `structuredContent.plaintext`, or error if not found.

---

#### `get-note-details`

Retrieves metadata about a note (without full content).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `title` | string | Yes | Exact title of the note |
| `account` | string | No | Account containing the note (defaults to Notes.app's default account; exact or unique-prefix match) |

**Example:**
```json
{
  "title": "Project Plan"
}
```

**Returns:** JSON with note metadata:
```json
{
  "id": "x-coredata://...",
  "title": "Project Plan",
  "created": "2025-01-15T10:30:00.000Z",
  "modified": "2025-01-20T14:22:00.000Z",
  "shared": false,
  "passwordProtected": false,
  "account": "iCloud"
}
```

---

#### `get-note-by-id`

Retrieves a note using its unique CoreData identifier.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The CoreData URL identifier (e.g., `x-coredata://...`) |

**Returns:** JSON with note metadata, or error if not found.

---

#### `show-note`

Reveals a note in Notes.app using its unique CoreData identifier.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The CoreData URL identifier (e.g., `x-coredata://...`) |
| `separately` | boolean | No | Open in a separate note window when supported by Notes.app |

**Returns:** Confirmation that Notes.app accepted the show command.

---

#### `update-note`

Updates an existing note's content and/or title.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | Exact CoreData note ID returned by a read or search |
| `expectedContentHash` | string | Yes | `contentHash` from the exact note version being replaced |
| `newTitle` | string | No | New title (if changing the title; ignored when `format` is `"html"`) |
| `newContent` | string | Yes | New content for the note body |
| `format` | string | No | Content format: `"plaintext"` (default) or `"html"`. When `"html"`, content replaces the entire note body as raw HTML and `newTitle` is ignored (the first HTML element serves as the title) |
| `allowLinkChanges` | boolean | No | Set to `true` only when intentionally changing or removing existing links |

Title-only updates are rejected because Apple Notes titles are not unique.

**Example - Using ID (recommended):**
```json
{
  "id": "x-coredata://ABC123/ICNote/p456",
  "expectedContentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "newContent": "Updated content here"
}
```

**Example - Update with HTML formatting:**
```json
{
  "id": "x-coredata://ABC123/ICNote/p456",
  "expectedContentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "newContent": "<p>New findings with <b>bold</b> emphasis.</p><pre><code>console.log('hello');</code></pre>",
  "format": "html"
}
```

**Returns:** Confirmation with the exact ID, post-save `contentHash`, and
`verifiedVisibleText: true`, or an error if the note changed before saving.
Apple Notes normalizes HTML, so this proves the visible text after saving, not
byte-identical rich formatting.

**Note:** `newContent` **replaces the entire note body** — it is not appended. To add to a note, prefer [`append-to-note`](#append-to-note), which does the read-and-concatenate for you and always round-trips the body as HTML. If you do read-modify-write by hand, note that `get-note-content` replaces oversized inline images with text placeholders (see [`get-note-content`](#get-note-content)) — writing that body back bakes the placeholders in.

**Rich-content safety:** `update-note` refuses to replace a note when its rich
metadata is unavailable or it contains attachments, native tags, inline
objects, or checklists that AppleScript cannot preserve. Existing link
destinations must remain present unless `allowLinkChanges` is explicitly set.

---

#### `delete-note`

Deletes a note (moves to Recently Deleted in Notes.app).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | Exact CoreData note ID returned by a read or search |
| `expectedContentHash` | string | Yes | `contentHash` from the exact note version being deleted |

Title-only deletion is rejected. If the note changed after the supplied hash
was read, deletion is also rejected.

**Example - Using ID (recommended):**
```json
{
  "id": "x-coredata://ABC123/ICNote/p456",
  "expectedContentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
```

**Returns:** Confirmation message, or error if note not found.

**⚠️ Safety:** Irreversible from the agent's side — requires explicit user confirmation before calling. Prefer `search-notes` / `list-notes` first to confirm the exact id(s) being deleted.

---

#### `move-note`

Moves a note to a different folder. The note is relocated in place via Notes.app's native `move`, so its id, creation date, and all embedded attachments (files, images, scans, PDFs, audio) are preserved. The destination folder must already exist — create it first with [`create-folder`](#create-folder).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | Exact CoreData note ID returned by a read or search |
| `folder` | string | Yes | Destination folder name or nested path (e.g., `"Work/Clients"`) |

Title-only moves are rejected.

**Example - Using ID (recommended):**
```json
{
  "id": "x-coredata://ABC123/ICNote/p456",
  "folder": "Archive"
}
```

**Returns:** Confirmation only after the same note ID is read back and its
actual destination folder ID matches the requested folder.

---

#### `append-to-note`

Appends or prepends content to an existing note without replacing it. Always reads and writes as HTML, preserving all existing rich formatting.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | Exact CoreData note ID returned by a read or search |
| `expectedContentHash` | string | Yes | `contentHash` from the exact note version being extended |
| `content` | string | Yes | Text to append to the note body |
| `position` | string | No | `"after"` (default) appends to the end; `"before"` prepends to the start |
| `separator` | string | No | String placed between existing content and new content (default: two newlines → `<div><br></div>` in HTML) |
| `format` | string | No | Format of the content being appended: `"plaintext"` (default) or `"html"` |

Title-only appends are rejected.

**Example - Append plaintext:**
```json
{
  "id": "x-coredata://ABC123/ICNote/p456",
  "expectedContentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "content": "New item added today"
}
```

**Example - Prepend HTML:**
```json
{
  "id": "x-coredata://ABC123/ICNote/p456",
  "expectedContentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "content": "<div><b>Status:</b> done</div>",
  "format": "html",
  "position": "before"
}
```

**Returns:** Confirmation with the exact ID, post-save `contentHash`, and
`verifiedVisibleText: true`. Apple Notes normalizes HTML, so this proves the
visible text after saving, not byte-identical rich formatting. Warns when the
note is shared with collaborators.

**Safety:** The append is rejected if the note changed since it was read, rich
metadata is unavailable, or the note contains attachments. Existing link
destinations are verified after saving.

**Notes containing native objects** (a table, a checklist, native tags) cannot be
spliced, so they are routed to the native end-append bridge — see
[`append-native`](#append-native). That path additionally requires `scopeText`,
keeps the default blank-line `separator` and `position: "after"`, and accepts a
fixed HTML subset rather than anything Notes.app can render:

| | Native append |
|---|---|
| Elements | `<a>` `<b>` `<br>` `<code>` `<del>` `<div>` `<em>` `<h1>` `<h2>` `<h3>` `<i>` `<li>` `<ol>` `<p>` `<s>` `<span>` `<strong>` `<table>` `<tbody>` `<td>` `<th>` `<thead>` `<tr>` `<tt>` `<u>` `<ul>` |
| Attributes | `href` on `<a>` (`https:`, `http:`, `notes:`, `applenotes:`, `mailto:` only) and a `font-size` style on `<span>`, e.g. `<span style="font-size: 18px">` — the form Notes itself stores a heading as |
| Refused | every other element and attribute, by name, naming the accepted subset; `<table>` here (use [`create-table`](#create-table)); comments, doctype and processing instructions |

Ordinary notes take the guarded HTML path and are not restricted to that subset.

---

#### `get-note-link`

Returns the `notes://showNote?identifier=<uuid>` deep-link URL for a note. The URL opens the note in Notes.app on iOS and macOS and can be stored in Reminders tasks or shared links.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | No | Note ID (preferred - more reliable than title) |
| `title` | string | No | Note title (use `id` instead when available) |
| `account` | string | No | Account containing the note (defaults to Notes.app's default account; exact or unique-prefix match, ignored if `id` is provided) |

**Note:** Either `id` or `title` must be provided. Using `id` is recommended. Password-protected notes cannot be linked.

**Example:**
```json
{
  "id": "x-coredata://ABC123/ICNote/p456"
}
```

**Returns:** `notes://showNote?identifier=<uuid>` URL string, plus the note id and title.

**Note:** Requires Full Disk Access for the app that launches the server so the Notes SQLite database is readable. On macOS 12–15 the tool also falls back to the AppleScript `note link` property. Run the `doctor` tool to verify access.

---

#### `list-notes`

Lists all notes, optionally filtered by folder, date, and limit.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `account` | string | No | Account to list notes from (defaults to Notes.app's default account; exact or unique-prefix match) |
| `folder` | string | No | Filter to notes in this folder only (supports nested paths like `"Work/Clients"`) |
| `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
| `limit` | number | No | Maximum number of notes to return |

**Example - All notes:**
```json
{}
```

**Example - Notes in a folder:**
```json
{
  "folder": "Work"
}
```

**Example - Recent notes with limit:**
```json
{
  "modifiedSince": "2025-06-01",
  "limit": 20
}
```

**Returns:** List of notes as `{title, id}` pairs — `notes: Array<{title, id}>`, plus `count`. The human-readable line is `  - <title> [id: <id>]`.

Use the returned `id` for any follow-up read/update/move/delete rather than re-resolving the title: titles are not unique, and a by-title lookup resolves a duplicated title to the same one note every time, silently skipping the others.

> **Changed in 2.7.0:** `notes` was previously `string[]` (titles only). Callers that treated the array as strings must now read `.title`.

---

#### `get-selected-notes`

Reads the currently selected note(s) from the Notes.app UI.

**Parameters:** None

**Returns:** Selected note metadata, including IDs for follow-up operations. Returns an empty list when Notes.app has no selected note.

---

### Folder Operations

#### `list-folders`

Lists all folders in an account with full hierarchical paths.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `account` | string | No | Account to list folders from (defaults to Notes.app's default account; exact or unique-prefix match) |

**Example:**
```json
{}
```

**Returns:** List of folders with IDs, paths, account names, and shared state. Nested folders are shown as full paths (e.g., `Work/Clients/Omnia`). Duplicate folder names are disambiguated by their full path. Literal slashes in folder names are escaped as `\/` (e.g., `Spain\/Portugal 2023`).

---

#### `create-folder`

Creates a new folder, including a whole nested hierarchy in one call.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Folder name, or a nested path separated by `/` (e.g. `"Retro Tech/PC/CPUs"`). Every intermediate folder is created; segments that already exist are skipped |
| `account` | string | No | Account to create folder in (defaults to Notes.app's default account; exact or unique-prefix match) |

**Example:**
```json
{
  "name": "Work Projects"
}
```

**Example - Create a nested hierarchy:**
```json
{
  "name": "Work/Clients/Omnia"
}
```

**Returns:** Confirmation message. The call is **idempotent** — an already-existing folder (or path segment) is skipped rather than treated as an error, so it is safe to call before every `create-note` that targets a folder.

---

#### `get-folder-by-id`

Reads one exact folder's current name and parent ID. Use these values with
`rename-folder`; this avoids relying on ambiguous folder names or paths.

---

#### `rename-folder`

Renames an existing folder in place using its exact `id`, `expectedName`,
`expectedParentId`, and `newName`. The operation preserves the folder ID, notes,
and descendants. It refuses stale metadata and a conflicting sibling name.

---

#### `delete-folder`

Deletes a folder.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Name or path of the folder to delete (supports nested paths like `"Work/Old"`) |
| `account` | string | No | Account containing the folder (defaults to Notes.app's default account; exact or unique-prefix match) |

**Example:**
```json
{
  "name": "Old Projects"
}
```

**Returns:** Confirmation message, or error if folder not found or not empty.

**⚠️ Safety:** Irreversible — requires explicit user confirmation before calling. Prefer `list-folders` first to confirm the exact folder path being deleted.

---

#### `show-folder`

Reveals a folder in Notes.app using its unique CoreData identifier.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The folder's CoreData identifier (from `list-folders`) |
| `separately` | boolean | No | Open in a separate window when supported by Notes.app |

**Returns:** Confirmation that Notes.app accepted the show command.

---

### Account Operations

#### `list-accounts`

Lists all configured Notes accounts.

**Parameters:** None

**Example:**
```json
{}
```

**Returns:** List of accounts with names, IDs, upgraded state, and default folder metadata.

---

#### `get-default-location`

Returns the default account and folder Notes.app uses for newly created notes.

**Parameters:** None

**Returns:** Default account and folder metadata, including IDs and shared state.

---

#### `show-account`

Reveals an account in Notes.app using its unique CoreData identifier.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The account's CoreData identifier (from `list-accounts`) |
| `separately` | boolean | No | Open in a separate window when supported by Notes.app |

**Returns:** Confirmation that Notes.app accepted the show command.

---

### Batch Operations

#### `batch-delete-notes`

Deletes multiple notes at once by ID.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `notes` | object[] | Yes | Array of `{id, expectedContentHash}` snapshots to delete (max 500 per request) |

**Returns:** Summary of successes and failures.

**⚠️ Safety:** Irreversible — requires explicit user confirmation before calling. Prefer `search-notes` / `list-notes` first to confirm the exact ids being deleted.

---

#### `batch-move-notes`

Moves multiple notes to a folder.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `ids` | string[] | Yes | Array of note IDs to move (max 500 per request) |
| `folder` | string | Yes | Destination folder name or nested path (e.g., `"Work/Clients"`). Must already exist — create it with [`create-folder`](#create-folder) |
| `account` | string | No | Account containing the folder |

**Returns:** Summary of successes and failures. Each success is reported only
after the note's actual container folder ID matches the destination folder ID.

---

### Export Operations

#### `export-notes-json`

Exports notes as JSON — metadata, HTML content, and plaintext, grouped by account and folder — one page at a time. A whole library rarely fits in one MCP message (note bodies embed images as base64), so each call returns a page and says where the next one starts.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `offset` | number | No | 0-based position to start from, counting notes in account → folder → note order (default `0`). Pass the previous page's `page.nextOffset` |
| `limit` | number | No | Maximum notes in this page (default `50`, max `500`). A page holds fewer when it reaches the response size limit |
| `modifiedSince` | string | No | ISO 8601 date string; export only notes modified on or after this date (e.g., `"2025-01-01"`). Keep the same value while paging |

**Example - First page:**
```json
{}
```

**Example - A later page of an incremental backup:**
```json
{
  "offset": 50,
  "modifiedSince": "2025-06-01"
}
```

**Returns:** `exportDate`, `version`, `accounts` (every account and folder, holding the notes that fall in this page), `summary` (`totalNotes` in this page, `totalFolders`, `totalAccounts`), and `page`:

| Field | Description |
|-------|-------------|
| `offset` / `limit` | The window that was applied |
| `totalAvailable` | Notes in the library, after `modifiedSince` |
| `returned` | Notes in this page |
| `nextOffset` | Where the next page starts; absent on the last page |
| `hasMore` | `true` until the last page — call again with `offset` set to `nextOffset` |
| `stoppedAtSizeLimit` | `true` when the page closed early to stay under the response size limit |

**Size limit:** each response stays under `APPLE_NOTES_MCP_EXPORT_MAX_BYTES` (default 8 MB), below the 10 MB per-message limit of MCP SDK stdio clients, which drop the connection on anything larger without passing on any error text. A note too large to fit on its own is still returned: its oversized inline images are replaced with placeholders (`strippedImages`), or failing that its HTML body, and if necessary its plaintext, is left empty with `contentOmitted: true`. Read such a note with `get-note-content`, and its files with `list-attachments` / `save-attachment`. Lower `limit` if your MCP client caps tool output below that size.

**Paging:** positions are worked out on every call, so notes created, deleted, or moved between calls can shift a page. Page through promptly, or use `modifiedSince` for incremental backups.

---

#### `get-note-markdown`

Gets a note's content as Markdown instead of HTML. If the note contains checklists and Full Disk Access is granted, checklist items are automatically annotated with `[x]` (done) or `[ ]` (undone).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | No | Note ID (preferred) |
| `title` | string | No | Note title |
| `account` | string | No | Account containing the note |

**Returns:** Note content converted to Markdown format. Checklist items include `[x]`/`[ ]` prefixes when database access is available.

---

#### `get-checklist-state`

Reads checklist done/undone state for a note. This bypasses the AppleScript limitation where `body of note` strips checklist state, by reading directly from the NoteStore SQLite database.

**Requires:** Full Disk Access for the MCP host process (see [Full Disk Access Setup](#full-disk-access)).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | Note ID (use `search-notes` to find it first) |

**Example:**
```json
{
  "id": "x-coredata://ABC123/ICNote/p456"
}
```

**Returns:** Checklist items with done/undone state and progress count:
```
Checklist for "Shopping List" (2/4 done):
[x] Buy milk
[x] Get bread
[ ] Pick up laundry
[ ] Call dentist
```

---

#### `get-note-metadata` (BETA)

Reads note metadata that AppleScript cannot expose, by querying the NoteStore SQLite database directly: pinned state, checklist flags, trash/recovery state, the preview snippet, and the password hint. The available fields vary by macOS version.

**Requires:** Full Disk Access for the MCP host process (see [Full Disk Access Setup](#full-disk-access)).

**BETA:** the NoteStore schema changes between macOS releases, so some fields can be absent on older or newer systems. The database is only ever read, never written.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | Note ID (use `search-notes` to find it first) |

**Returns:** A metadata object in `structuredContent` holding any of `pinned`, `hasChecklist`, `hasChecklistInProgress`, `recoveringFromTrash`, `passwordProtected`, `passwordHint`, `snippet`, `widgetSnippet`, and `smartFolderQuery`. Unlike most read tools, it also resolves trashed notes that AppleScript can no longer find.

---

#### `add-attachment`

Adds one nonempty local file of at most 64 MiB to an exact note using `id`, the
latest `expectedContentHash`, and an absolute `path`. The server never retries
the insertion. It verifies that existing rich content survived and compares the
fetched attachment bytes with the source before reporting success.

---

#### `list-attachments`

Lists attachments in a note.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | No | Note ID (preferred) |
| `title` | string | No | Note title |
| `account` | string | No | Account containing the note |

**Returns:** List of attachments with IDs, names, content identifiers, URLs when available, created/modified dates, and shared state.

**⚠️ Safety:** A lookup failure is reported as an error, never as an empty list — so an empty result reliably means the note has no attachments and is safe to replace wholesale. Treat an error as "unknown", not "none".

---

#### `save-attachment`

Saves a note attachment to disk.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `noteId` | string | Yes | CoreData note ID (from `search-notes`/`list-notes`) |
| `attachmentId` | string | Yes | Attachment ID (from `list-attachments`) |
| `savePath` | string | Yes | Absolute destination file path. Must be under your home directory, a temp directory, or `/Volumes` |

**Returns:** Confirmation with the saved path, name, and content type (also in `structuredContent`).

---

#### `fetch-attachment`

Returns a note attachment's bytes as base64, without writing to disk (the read counterpart to `save-attachment`).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `noteId` | string | Yes | CoreData note ID (from `search-notes`/`list-notes`) |
| `attachmentId` | string | Yes | Attachment ID (from `list-attachments`) |

**Returns:** The attachment name, content type, byte count, and base64 payload in `structuredContent.base64`.

---

#### `show-attachment`

Reveals one note attachment in Notes.app. Attachments are elements of a note, so this takes both the note id and the attachment id (the same pair used by `save-attachment` / `fetch-attachment`).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `noteId` | string | Yes | CoreData note ID (from `search-notes`/`list-notes`) |
| `attachmentId` | string | Yes | Attachment ID (from `list-attachments`) |
| `separately` | boolean | No | Open in a separate window when supported by Notes.app |

**Returns:** Confirmation that Notes.app revealed the attachment.

---

### Diagnostics

#### `health-check`

Verifies Notes.app connectivity and permissions.

**Parameters:** None

**Returns:** Status of all health checks (app installed, permissions, account access).

---

#### `doctor`

Run a full setup diagnostic: Notes.app reachability, the Automation permission, configured accounts, and Full Disk Access — each reported as ok / warn / fail with an actionable message. This is the richer counterpart to `health-check`; reach for it first when something isn't working.

**Parameters:** None

**Returns:** A per-check report (`structuredContent` carries the raw `{healthy, checks[]}`). The Full Disk Access check tells you whether checklist-state features will work — see [Full Disk Access Setup](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md).

---

#### `get-notes-stats`

Gets comprehensive statistics about your notes.

**Parameters:** None

**Returns:** Total counts, per-account breakdown, folder statistics, and recently modified counts.

The `structuredContent` also includes a `coverage` object — `{ complete, scanned, covered, warnings[] }`. If `complete` is `false`, one or more accounts (or the recent-activity scan) could not be read and the counts reflect only the scopes that succeeded; the text output adds a "⚠️ Partial results" line. This lets you tell a genuinely empty library apart from a partial failure.

---

#### `get-sync-status`

Checks iCloud sync status.

**Parameters:** None

**Returns:** Whether sync is active, pending uploads, and last activity time.

---

#### `list-shared-notes`

Lists all notes shared with collaborators.

**Parameters:** None

**Returns:** List of shared notes with warnings about collaboration.

---

### Native background operations

Install the signed workflows once as described in
[`shortcuts/README.md`](shortcuts/README.md), then run each once in the
foreground in Shortcuts.app and choose **Always Allow** — again after every
upgrade — so no first-run consent prompt is left for a background run that
cannot display it. Native writes never use UI
automation or write directly to the Notes database. Every operation on an
existing note requires an exact note ID, a fresh `expectedContentHash`, and a
distinctive existing `scopeText` so the Shortcut and server can independently
resolve the same note. Creating a note from Markdown is
[`create-note`](#create-note) with `format: "markdown"`.

#### `get-capabilities`

Reports which background operations are implemented, live-verified, installed,
and currently available, with a specific reason for each unavailable operation.

#### `append-native`

Appends bounded plaintext, semantic HTML, or Markdown while preserving existing
native objects. The regular `append-to-note` tool routes protected notes here
when `scopeText` is supplied. Use a distinctive phrase of plain words without
punctuation, hashtags, or paths because Notes search may not resolve them
literally. `format: "html"` accepts the fixed subset tabulated under
[`append-to-note`](#append-to-note); content outside it is refused by name, with
the accepted subset in the error. `format: "markdown"` accepts the same bounded
subset as `format: "html"`'s Markdown-shaped content (`#`/`##`/`###` headings,
flat lists, emphasis, and inline links) but is sent through Notes' own native
Markdown importer rather than converted to HTML first, so `#`/`##`/`###`
produce real Title/Heading/Subheading — Notes' HTML importer only
distinguishes two heading levels and renders `###` the same as `##`. Markdown
that the importer would rewrite, so the appended text could not be verified, is
refused before anything is written; the list is under
[Markdown notes](#markdown-notes). A
transport failure names the Shortcut it was waiting on, so a missing or
unapproved bridge is identified rather than guessed at; a timeout also says it
may be an unanswered first-run consent prompt and names the Shortcut to run once
in the foreground.

#### `create-checklist-item`

Appends one real unchecked Notes checklist item and verifies its native identity
and text.

#### `create-table`

Appends a native table from rectangular string rows and verifies every decoded
cell. It never substitutes a text table.

#### `set-note-pinned`

Sets an explicit pinned state after checking both the expected current state and
the note revision. It does not rewrite the body.

#### `remove-native-tags`

Removes specified tags from one exact note while preserving unrelated tags and
native objects. It does not delete global tag definitions.

#### `replace-native-tag`

Adds and verifies the new tag before removing the old tag on an explicit list of
freshly read notes. Stops on the first uncertain result. Smart Folder rules are
not changed.

#### `insert-note-link`

Retrieves another note's real deep link and appends it with a static label while
preserving the target note's native objects.

## Usage Patterns

### Basic Workflow

```
User: "Create a note called 'Todo' with my tasks for today"
AI: [calls create-note with title="Todo", content="Tasks for today..."]
    "I've created a note called 'Todo' with your tasks."

User: "What notes do I have?"
AI: [calls list-notes]
    "You have 15 notes: Todo, Shopping List, Meeting Notes..."

User: "Show me the Shopping List"
AI: [calls get-note-content with title="Shopping List"]
    "Here's your shopping list: - Milk - Eggs - Bread..."
```

### Working with Accounts

Omit `account` and an operation targets whichever account **Notes.app itself
reports as the default** — not a hardcoded "iCloud". That matters if your default
is a non-iCloud account, if the account name is localized, or if it carries a
trailing U+F8FF () character.

```
User: "What accounts do I have?"
AI: [calls list-accounts]
    "You have 3 accounts: iCloud, Gmail, Exchange"

User: "List notes in my Gmail account"
AI: [calls list-notes with account="Gmail"]
    "Your Gmail account has 5 notes..."
```

When you do pass `account`, it is resolved in this order:

1. **Exact name match** wins outright.
2. A **unique prefix match** resolves — `account="robert"` finds
   `robert.b.sweet@gmail.com`.
3. An **ambiguous prefix is refused**, with every candidate named:

   ```
   Account "rob" is ambiguous - it matches 2 accounts:
   rob@superiortech.io, robert.b.sweet@gmail.com. Use the full account name.
   ```

That third rule is deliberate. Silently taking the *first* prefix match would
make `delete-note` or `move-note` land in the wrong account and report success.
An unresolvable account is reported as such rather than as "note not found", so
you are not sent looking for the wrong problem.

### Organizing with Folders

```
User: "Create a folder called 'Archive'"
AI: [calls create-folder with name="Archive"]
    "Created folder 'Archive'"

User: "Move my old meeting notes to Archive"
AI: [searches for the note, then calls move-note with its exact id and folder="Archive"]
    "Moved 'Old Meeting Notes' to 'Archive'"

User: "What folders do I have?"
AI: [calls list-folders]
    "You have 5 folders: Work, Work/Clients, Work/Clients/Omnia, Archive, Recipes"

User: "Create a note in Work/Clients about Acme Corp"
AI: [calls create-note with title="Acme Corp", content="...", folder="Work/Clients"]
    "Created 'Acme Corp' in Work/Clients"
```

---

## Installation Options

### npm (Recommended)

```bash
npm install -g apple-notes-mcp
```

### From Source

```bash
git clone https://github.com/sweetrb/apple-notes-mcp.git
cd apple-notes-mcp
```

The repo ships a prebuilt, dependency-free `build/index.js`, so a bare clone runs with nothing but Node installed. `pnpm install` and `pnpm run build` are only needed when you change the source (development uses [pnpm](https://pnpm.io/), not npm).

You can also install straight from the git repo with `npm install -g github:sweetrb/apple-notes-mcp` (building from source requires pnpm), but the published npm package above is the recommended path.

If installed from source, use this configuration:
```json
{
  "mcpServers": {
    "apple-notes": {
      "command": "node",
      "args": ["/path/to/apple-notes-mcp/build/index.js"]
    }
  }
}
```

#### Running from a clone in Claude Code (project-scope `.mcp.json`)

This repo ships a `.mcp.json` at its root so that, when you run `claude` from inside a clone, the server is registered automatically as a **project-scope** server — no manual config needed. Just launch Claude Code from the repo directory and approve the server when prompted (the bundled `build/index.js` is committed, so no build step is required).

The entrypoint is written as (an excerpt of that file, not a whole config):

```text
"args": ["${CLAUDE_PROJECT_DIR:-.}/build/index.js"]
```

`CLAUDE_PROJECT_DIR` is the variable Claude Code injects into a project/user-scoped server's environment, and it resolves to the repo root. **You must launch `claude` from inside the repo** for this to work — the bare `.` fallback is only a last resort and is *not* reliable, because it resolves against the launching process's working directory, not the repo.

> **Why not `${CLAUDE_PLUGIN_ROOT}`?** `CLAUDE_PLUGIN_ROOT` is set **only** for marketplace plugin installs, never for a project-scope clone, so it can't drive the clone workflow. Conversely, a plugin install can't use `CLAUDE_PROJECT_DIR` (in a plugin, that points at the *user's* project, not the plugin's own directory). Claude Code does **not** support nested defaults like `${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR:-.}}`, so a single entrypoint string cannot serve both contexts. The two distribution paths are therefore decoupled: the **plugin** carries its own MCP config in `.claude-plugin/plugin.json` (using `${CLAUDE_PLUGIN_ROOT}`), while the root `.mcp.json` is dedicated to the **clone** workflow (using `${CLAUDE_PROJECT_DIR:-.}`). Because `plugin.json` declares its own `mcpServers`, the plugin does not also auto-load the root `.mcp.json`, so there is no double-registration.

> **Heads-up on scope precedence:** project-scope (`.mcp.json`) outranks user-scope. If you *also* have an `apple-notes` entry registered at user scope (e.g. an absolute path in `~/.claude.json`), the project-scope entry wins and the user-scope one is ignored entirely. Pick one — for local development on this repo, the project-scope `.mcp.json` is the intended source. To pin a specific local build instead, register it at **local** scope (`claude mcp add apple-notes -s local -- node /abs/path/build/index.js`), which outranks project scope.

---

## Configuration

### Environment variables

All configuration is optional — the server works out of the box. Override behavior with these variables (set them in your MCP client's `env` block, or via the [config file](#configuration-file-when-the-host-strips-env) below):

| Variable | Default | Description |
|----------|---------|-------------|
| `APPLE_NOTES_MCP_MAX_BUFFER` | `67108864` (64 MB) | Max bytes captured from a single AppleScript invocation. Raise it if a very large export/list is truncated; lower it to cap memory. |
| `APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES` | `26214400` (25 MB) | Max size of an attachment that [`fetch-attachment`](#fetch-attachment) will base64-encode inline. Larger attachments are rejected with an error pointing at [`save-attachment`](#save-attachment) (which streams to disk and has no such limit). Raise it to fetch bigger attachments inline; lower it to cap memory. |
| `APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES` | `262144` (256 KB) | Per-image cap on the base64 payload kept inline in a [`get-note-content`](#get-note-content) response. Inline images over the cap are replaced with placeholders (with a warning appended) so an image-heavy note cannot exceed the MCP client's message limit and drop the connection; export the real files with [`save-attachment`](#save-attachment) or [`fetch-attachment`](#fetch-attachment). Raise it to keep bigger images inline. |
| `APPLE_NOTES_MCP_CONFIG_FILE` | `~/Library/Application Support/apple-notes-mcp/config.json` | Path to the JSON config file (see below). |
| `APPLE_NOTES_MCP_TIMEOUT_MS` | `30000` (30 s) | Total AppleScript operation timeout, including retry attempts and delays. Raise it if full-library operations (large searches, exports) time out on a big Notes library. Per-call `timeoutMs` options still win. |
| `APPLE_NOTES_MCP_EXPORT_MAX_BYTES` | `8388608` (8 MB) | Largest response `export-notes-json` sends; a page closes early to stay under it. The default sits below the 10 MB per-message limit of MCP SDK stdio clients, which drop the connection on anything larger. Raise it only if your MCP client accepts bigger messages. |
| `APPLE_NOTES_MCP_MAX_RETRIES` | `2` | Maximum attempts for a read-only AppleScript call that fails with a **transient** error (Notes.app busy / not responding / lost connection). `2` means one retry; set `1` to fail fast with no retries. Retries share the single `APPLE_NOTES_MCP_TIMEOUT_MS` budget rather than each getting a fresh one, and a retry is skipped when under a second of that budget remains — so this is a ceiling, not a guarantee. In particular a call that exhausts the budget with a **timeout** has no time left to retry by construction. Mutating operations run once because a timeout can occur after Notes.app applied the change. Non-transient errors (e.g. "note not found") never retry. |
| `APPLE_NOTES_MCP_RETRY_DELAY_MS` | `1000` (1 s) | Base delay before the first retry; subsequent retries back off exponentially (1s, 2s, 4s, ...). |
| `DEBUG` / `VERBOSE` | unset | Set either to enable verbose diagnostic logging to stderr. |

### Configuration file (when the host strips `env`)

Some host apps (e.g. Claude Desktop) launch the MCP server with a scrubbed
environment and ignore the `env` block in their server config, so there's no way
to pass `APPLE_NOTES_MCP_*` settings through it. In that case, put them in a JSON
file the host doesn't manage — `APPLE_NOTES_MCP_CONFIG_FILE`, or by default
`~/Library/Application Support/apple-notes-mcp/config.json`:

```json
{
  "APPLE_NOTES_MCP_MAX_BUFFER": "134217728",
  "DEBUG": "1"
}
```

The server reads it at startup and merges values into the environment **without
overriding** anything already set there (so an explicit `env` still wins). This
is the recommended way to configure the server under Claude Desktop. Apple Notes
MCP stores no secrets, but as a general rule keep only non-secret config here.

---

## Full Disk Access

Several tools read directly from the Apple Notes SQLite database, which lives in a macOS-protected directory. Those tools require **Full Disk Access** for the process running the MCP server: `get-checklist-state`, `get-note-metadata`, `get-note-link`, the checklist annotations in `get-note-markdown`, and the database half of `get-sync-status`.

> 📘 **For the full why-and-how walkthrough (which app to grant, verifying with `doctor`, graceful degradation), see the [Full Disk Access Setup Guide](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md).** The summary below is the quick version.

### How to Grant Full Disk Access

1. Open **System Settings** (or System Preferences on older macOS)
2. Go to **Privacy & Security > Full Disk Access**
3. Click the **+** button
4. Add the application that hosts the MCP server:
   - **Claude Desktop**: Add `/Applications/Claude.app`
   - **Terminal**: Add `/Applications/Utilities/Terminal.app`
   - **VS Code**: Add `/Applications/Visual Studio Code.app`
   - **iTerm**: Add `/Applications/iTerm.app`
5. Restart the application after granting access

### Without Full Disk Access

Every tool that does not read the Notes database works normally without Full Disk Access — that is the whole AppleScript surface (create, read, search, update, move, delete, folders, accounts, attachments, stats, export). The database-backed tools degrade like this:
- `get-checklist-state` returns an error explaining that database access is needed
- `get-note-metadata` returns the same kind of error — it has no non-database path
- `get-note-link` returns an error on macOS 26+; on macOS 12–15 it still works via the AppleScript `note link` fallback
- `get-note-markdown` returns plain list items without `[x]`/`[ ]` annotations (graceful fallback)
- `get-sync-status` still answers, but reports no pending uploads and no active sync — treat that as "unknown", not "idle"

---

## Security and Privacy

- **Local only** - All operations happen locally via AppleScript. No data is sent to external servers.
- **Permission required** - macOS will prompt for automation permission on first use.
- **Password-protected notes** - Notes with passwords cannot be read or modified via this server.
- **No credential storage** - The server doesn't store any passwords or authentication tokens.

---

## Known Limitations

| Limitation | Reason |
|------------|--------|
| macOS only | Apple Notes and AppleScript are macOS-specific |
| Batch ops run per-note | `batch-delete-notes` / `batch-move-notes` apply each note individually rather than as one bulk operation — AppleScript has no bulk equivalent to IMAP's `UID STORE`/`MOVE`. This is deliberate: it preserves per-note success/failure reporting. ([#26](https://github.com/sweetrb/apple-notes-mcp/issues/26)) |
| Pinned notes are read-only | AppleScript exposes no `pinned` property. Pin state is readable via the BETA `get-note-metadata` tool (NoteStore database, needs Full Disk Access) but cannot be set ([#28](https://github.com/sweetrb/apple-notes-mcp/issues/28)) |
| Limited rich formatting | Use `format: "html"` on create/update for headings, lists, bold, code blocks; some complex formatting may not render |
| Title matching | Most operations require exact title matches |
| Checklist state | Requires [Full Disk Access](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md) to read done/undone state from the database |
| Checklist **creation** | Not supported. AppleScript's `body of note` setter strips `<input type="checkbox">` and ignores any checklist-styling CSS class. Apple Notes stores checklist items as a protobuf paragraph style (`style_type=103`) that AppleScript doesn't expose, and the SQLite database is read-only. See [Creating Checklists](#creating-checklists) below for the workaround. |

### Creating Checklists

**There is no programmatic way to create a true Apple Notes checklist via AppleScript** — and therefore no way via this MCP server. This is an Apple limitation, not a bug.

When a note is created or updated via AppleScript:

| You send | What Notes.app actually renders |
|----------|--------------------------------|
| `<input type="checkbox"> Item` | `Item` (the `<input>` tag is stripped) |
| `<ul class="checklist"><li>Item</li></ul>` | A plain bulleted list — the `checklist` class is dropped |
| Markdown `- [ ] Item` (in `plaintext` mode) | The literal text `- [ ] Item` |

Apple Notes stores checklists as a paragraph style (`style_type=103`) inside a gzipped protobuf blob in the `NoteStore.sqlite` database. AppleScript's note `body` interface does not expose paragraph styles, and writing directly to the live database is unsafe.

**Workarounds:**

1. **Create the note with bulleted list items, then convert manually in Notes.app.** Select the items and press <kbd>⇧⌘L</kbd> (or **Format → Checklist**). This converts the list in place and the resulting checklist will be readable by `get-checklist-state` and annotated by `get-note-markdown`.
2. **Use the Apple Shortcuts app** to script the checklist creation, since Shortcuts can manipulate Notes content at a higher level than AppleScript.
3. **Read-only checklist support is fully implemented** — once a checklist exists (created manually or by another app), `get-checklist-state` and `get-note-markdown` will read its done/undone state correctly (with Full Disk Access).

If you need to *track* todos programmatically and don't strictly need them rendered as Apple Notes checklist UI, plain markdown-style `- [ ] item` / `- [x] item` lines in a `plaintext` note are a reasonable alternative — they are searchable, human-readable, and can be parsed by downstream tooling.

### Backslash Escaping (Important for AI Agents)

When sending content containing backslashes (`\`) to this MCP server, **you must escape them as `\\`** in the JSON parameters.

**Why:** The MCP protocol uses JSON for parameter passing. In JSON, a single backslash is an escape character. To include a literal backslash in content, it must be escaped as `\\`.

**Example - Shell command with escaped path:**
```json
{
  "title": "Install Script",
  "content": "cp ~/Library/Mobile\\ Documents/file.txt ~/.config/"
}
```
→ arrives as: `cp ~/Library/Mobile\ Documents/file.txt ~/.config/`

In a JSON string literal the two characters `\\` denote **one** literal backslash. Doubling them to `\\\\` denotes *two* backslashes in the note, which is almost never what you want.

**Example - Literal double backslash:**
```json
{
  "title": "Escaping Notes",
  "content": "Send \\\\ only when you want two backslashes"
}
```
→ arrives as: `Send \\ only when you want two backslashes`

**Common patterns requiring escaping:**
- Shell escaped spaces: `Mobile\ Documents` → `Mobile\\ Documents` in JSON
- Regex patterns: `\d+` → `\\d+` in JSON
- Literal double backslash: `\\` → `\\\\` in JSON

**If you see errors** when creating/updating notes with backslashes, double-check that backslashes are properly escaped in the JSON payload.

---

## Troubleshooting

### "Notes.app not responding"
- Ensure Notes.app is not frozen
- Try opening Notes.app manually
- Restart the MCP server

### "Permission denied"
- macOS needs automation permission
- Go to System Settings > Privacy & Security > Automation
- Ensure your terminal/Claude has permission to control Notes

### Native writes time out or report an uncertain outcome
- Symptom: `add-native-tags`, `set-note-pinned`, `append-native` or another native write fails with "Shortcuts timed out waiting for …", "Operation outcome uncertain" or "readback was not verified", while `doctor` and `get-capabilities` report the bridges installed
- Common cause, especially right after install or upgrade: the bridge Shortcut is waiting on a first-run consent prompt. The server runs it in the background, where Shortcuts cannot display that prompt, so the run stalls until it times out
- Fix: open Shortcuts.app, run the Shortcut the error names (`Apple Notes MCP - Native Tags`, `Apple Notes MCP - Background Operations v5` or `Apple Notes MCP - Create Markdown Note`) once in the foreground and choose **Always Allow**. Each bridge needs this once; quitting or relaunching Shortcuts.app or Notes.app does not clear it
- Read the exact note before retrying — the write may have landed

### "Note not found"
- Note titles must match exactly (case-sensitive)
- Check if the note is in a different account
- Use `list-notes` to see available notes

### Note creation/update fails silently with backslashes
- Content containing `\` characters requires JSON escaping
- Use `\\` to represent each literal backslash
- See "Backslash Escaping" section under Known Limitations

### Notes accumulate blank lines after repeated updates
- Repeatedly updating a note (especially with HTML content) can accumulate whitespace artifacts — `<div><br></div>` tags that persist between sections even after you remove them from your content
- Apple Notes' internal HTML processing preserves empty divs from previous edits, so the gaps are baked into the note's internal representation and cannot be fixed through further updates
- Fix: delete the note with `delete-note` and create a fresh one with `create-note`

### Every tool is refused: "invalid outputSchema … unsupported dialect"

If your client reports something like

```
Tool 'list-notes' has an invalid outputSchema: JSON Schema declares an unsupported
dialect ("$schema": "http://json-schema.org/draft-07/schema#"). The default
validator supports JSON Schema 2020-12 only.
```

you are on a version older than **2.7.2**. MCP standardized on JSON Schema
2020-12, and every tool this server advertised carried the older draft-07
dialect, so clients rejected all of them at once — nothing about your Notes
library, permissions, or configuration is involved.

- Fix: upgrade to 2.7.2 or later. `npx -y apple-notes-mcp@latest` picks it up on
  the next launch; a marketplace install updates through the marketplace.
- Running from a clone: `git pull && pnpm install && pnpm run build`, then
  restart the client.

### `apple-notes` server fails to connect when run from a clone
- Launch `claude` from **inside the repo directory** so `CLAUDE_PROJECT_DIR` resolves to the repo root (the bare `.` fallback is unreliable — it points at the launching process's working directory)
- If you've been editing the source, rerun `pnpm run build` — the entrypoint is `${CLAUDE_PROJECT_DIR:-.}/build/index.js`, and the committed bundle only reflects your changes after a rebuild
- Run `claude mcp list` to check for a conflicting `apple-notes` entry at another scope (project-scope outranks user-scope, but local-scope outranks project-scope)
- Approve the pending project-scope server when Claude Code prompts you

---

## Development

Development uses [pnpm](https://pnpm.io/) (see `packageManager` in `package.json`):

```bash
pnpm install            # Install dependencies
pnpm run build          # Typecheck, then bundle src/index.ts into build/index.js (esbuild)
pnpm test               # Run unit test suite (mocked AppleScript)
pnpm run test:integration  # Run integration tests against real Notes.app
pnpm run test:all       # Unit + integration
pnpm run lint           # Check code style
pnpm run format         # Format code
```

The integration suite (`test/integration.test.ts`) drives the real
`AppleNotesManager → AppleScript → Notes.app` stack — creating, reading,
searching, and deleting throwaway notes. Its live tests self-skip when no
writable Notes account is available (e.g. CI), so it is safe to run anywhere;
the pure path-safety and hashtag tests always run.

---

## Author

**Rob Sweet** - President, [Superior Technologies Research](https://www.superiortech.io)

A software consulting, contracting, and development company.

- Email: rob@superiortech.io
- GitHub: [@sweetrb](https://github.com/sweetrb)

## License

MIT License - see [LICENSE](https://github.com/sweetrb/apple-notes-mcp/blob/main/LICENSE) for details.

## Contributing

Contributions are welcome! Please see [CONTRIBUTING.md](https://github.com/sweetrb/apple-notes-mcp/blob/main/CONTRIBUTING.md) for guidelines.

## Related Projects

Part of a family of macOS MCP servers:

- [apple-mail-mcp](https://github.com/sweetrb/apple-mail-mcp) — MCP server for Apple Mail (read, search, send, and organize email)
- [apple-numbers-mcp](https://github.com/sweetrb/apple-numbers-mcp) — MCP server for Apple Numbers (read and write .numbers spreadsheets)
- [apple-photos-mcp](https://github.com/sweetrb/apple-photos-mcp) — MCP server for Apple Photos (query metadata and export originals)

## Recurring macOS permission prompts

If macOS keeps re-prompting for Full Disk Access or Automation for `node` (often after a `brew upgrade`), the cause is almost always an **ad-hoc-signed Node** (typically Homebrew's): its code signature (cdhash) changes on every update, so macOS TCC treats each new build as a brand-new binary and silently drops the grants you already made. The fix is to run this server under an official, **Developer-ID-signed Node at a stable path** — its signing identity stays the same across updates, so you grant the permission once and it persists. The `doctor` tool detects the ad-hoc-signature case and the full walkthrough is in [docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md).
