apple-notes-mcp 1.2.18 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ The AI assistant communicates with this server, which then uses AppleScript to i
25
25
  If you're using [Claude Code](https://claude.com/product/claude-code) (in Terminal or VS Code), just ask Claude to install it:
26
26
 
27
27
  ```
28
- Install the apple-notes-mcp MCP server so you can help me manage my Apple Notes
28
+ Install the sweetrb/apple-notes-mcp MCP server so you can help me manage my Apple Notes
29
29
  ```
30
30
 
31
31
  Claude will handle the installation and configuration automatically.
@@ -45,7 +45,7 @@ This method also installs a **skill** that teaches Claude when and how to use Ap
45
45
 
46
46
  **1. Install the server:**
47
47
  ```bash
48
- npm install -g apple-notes-mcp
48
+ npm install -g github:sweetrb/apple-notes-mcp
49
49
  ```
50
50
 
51
51
  **2. Add to Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
@@ -86,6 +86,7 @@ On first use, macOS will ask for permission to automate Notes.app. Click "OK" to
86
86
  | **Folder Management** | Create, list, and delete folders |
87
87
  | **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account |
88
88
  | **Batch Operations** | Delete or move multiple notes at once |
89
+ | **Checklist State** | Read checklist done/undone state directly from the Notes database |
89
90
  | **Export** | Export all notes as JSON or get individual notes as Markdown |
90
91
  | **Attachments** | List attachments in notes |
91
92
  | **Sync Awareness** | Detect iCloud sync in progress, warn about incomplete results |
@@ -109,6 +110,7 @@ Creates a new note in Apple Notes.
109
110
  | `title` | string | Yes | The title of the note (becomes first line) |
110
111
  | `content` | string | Yes | The body content of the note |
111
112
  | `tags` | string[] | No | Tags for organization (stored in metadata) |
113
+ | `format` | string | No | Content format: `"plaintext"` (default) or `"html"`. When `"html"`, content is used as raw HTML for rich formatting |
112
114
 
113
115
  **Example:**
114
116
  ```json
@@ -119,6 +121,15 @@ Creates a new note in Apple Notes.
119
121
  }
120
122
  ```
121
123
 
124
+ **Example - HTML formatting:**
125
+ ```json
126
+ {
127
+ "title": "Status Report",
128
+ "content": "<h1>Status Report</h1><h2>Summary</h2><p>All tasks <b>on track</b>.</p><ul><li>Feature A: complete</li><li>Feature B: in progress</li></ul>",
129
+ "format": "html"
130
+ }
131
+ ```
132
+
122
133
  **Returns:** Confirmation message with note title and ID. Save the ID for subsequent operations like `update-note`, `delete-note`, etc.
123
134
 
124
135
  ---
@@ -132,6 +143,9 @@ Searches for notes by title or content.
132
143
  | `query` | string | Yes | Text to search for |
133
144
  | `searchContent` | boolean | No | If `true`, searches note body; if `false` (default), searches titles only |
134
145
  | `account` | string | No | Account to search in (defaults to iCloud) |
146
+ | `folder` | string | No | Limit search to a specific folder |
147
+ | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
148
+ | `limit` | number | No | Maximum number of results to return |
135
149
 
136
150
  **Example - Search titles:**
137
151
  ```json
@@ -148,6 +162,16 @@ Searches for notes by title or content.
148
162
  }
149
163
  ```
150
164
 
165
+ **Example - Search recent notes with limit:**
166
+ ```json
167
+ {
168
+ "query": "todo",
169
+ "searchContent": true,
170
+ "modifiedSince": "2025-01-01",
171
+ "limit": 10
172
+ }
173
+ ```
174
+
151
175
  **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.
152
176
 
153
177
  ---
@@ -233,9 +257,10 @@ Updates an existing note's content and/or title.
233
257
  |-----------|------|----------|-------------|
234
258
  | `id` | string | No | Note ID (preferred - more reliable than title) |
235
259
  | `title` | string | No | Current title of the note to update (use `id` instead when available) |
236
- | `newTitle` | string | No | New title (if changing the title) |
260
+ | `newTitle` | string | No | New title (if changing the title; ignored when `format` is `"html"`) |
237
261
  | `newContent` | string | Yes | New content for the note body |
238
262
  | `account` | string | No | Account containing the note (defaults to iCloud, ignored if `id` is provided) |
263
+ | `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) |
239
264
 
240
265
  **Note:** Either `id` or `title` must be provided. Using `id` is recommended.
241
266
 
@@ -264,6 +289,15 @@ Updates an existing note's content and/or title.
264
289
  }
265
290
  ```
266
291
 
292
+ **Example - Update with HTML formatting:**
293
+ ```json
294
+ {
295
+ "id": "x-coredata://ABC123/ICNote/p456",
296
+ "newContent": "<h1>Updated Report</h1><p>New findings with <b>bold</b> emphasis.</p><pre><code>console.log('hello');</code></pre>",
297
+ "format": "html"
298
+ }
299
+ ```
300
+
267
301
  **Returns:** Confirmation message, or error if note not found.
268
302
 
269
303
  ---
@@ -335,12 +369,14 @@ Moves a note to a different folder.
335
369
 
336
370
  #### `list-notes`
337
371
 
338
- Lists all notes, optionally filtered by folder.
372
+ Lists all notes, optionally filtered by folder, date, and limit.
339
373
 
340
374
  | Parameter | Type | Required | Description |
341
375
  |-----------|------|----------|-------------|
342
376
  | `account` | string | No | Account to list notes from (defaults to iCloud) |
343
377
  | `folder` | string | No | Filter to notes in this folder only |
378
+ | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
379
+ | `limit` | number | No | Maximum number of notes to return |
344
380
 
345
381
  **Example - All notes:**
346
382
  ```json
@@ -354,6 +390,14 @@ Lists all notes, optionally filtered by folder.
354
390
  }
355
391
  ```
356
392
 
393
+ **Example - Recent notes with limit:**
394
+ ```json
395
+ {
396
+ "modifiedSince": "2025-06-01",
397
+ "limit": 20
398
+ }
399
+ ```
400
+
357
401
  **Returns:** List of note titles.
358
402
 
359
403
  ---
@@ -476,7 +520,7 @@ Exports all notes as a JSON structure.
476
520
 
477
521
  #### `get-note-markdown`
478
522
 
479
- Gets a note's content as Markdown instead of HTML.
523
+ 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).
480
524
 
481
525
  | Parameter | Type | Required | Description |
482
526
  |-----------|------|----------|-------------|
@@ -484,7 +528,35 @@ Gets a note's content as Markdown instead of HTML.
484
528
  | `title` | string | No | Note title |
485
529
  | `account` | string | No | Account containing the note |
486
530
 
487
- **Returns:** Note content converted to Markdown format.
531
+ **Returns:** Note content converted to Markdown format. Checklist items include `[x]`/`[ ]` prefixes when database access is available.
532
+
533
+ ---
534
+
535
+ #### `get-checklist-state`
536
+
537
+ 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.
538
+
539
+ **Requires:** Full Disk Access for the MCP host process (see [Full Disk Access Setup](#full-disk-access-for-checklist-features)).
540
+
541
+ | Parameter | Type | Required | Description |
542
+ |-----------|------|----------|-------------|
543
+ | `id` | string | Yes | Note ID (use `search-notes` to find it first) |
544
+
545
+ **Example:**
546
+ ```json
547
+ {
548
+ "id": "x-coredata://ABC123/ICNote/p456"
549
+ }
550
+ ```
551
+
552
+ **Returns:** Checklist items with done/undone state and progress count:
553
+ ```
554
+ Checklist for "Shopping List" (2/4 done):
555
+ [x] Buy milk
556
+ [x] Get bread
557
+ [ ] Pick up laundry
558
+ [ ] Call dentist
559
+ ```
488
560
 
489
561
  ---
490
562
 
@@ -595,7 +667,7 @@ AI: [calls move-note with title="Old Meeting Notes", folder="Archive"]
595
667
  ### npm (Recommended)
596
668
 
597
669
  ```bash
598
- npm install -g apple-notes-mcp
670
+ npm install -g github:sweetrb/apple-notes-mcp
599
671
  ```
600
672
 
601
673
  ### From Source
@@ -621,6 +693,30 @@ If installed from source, use this configuration:
621
693
 
622
694
  ---
623
695
 
696
+ ## Full Disk Access for Checklist Features
697
+
698
+ The `get-checklist-state` tool and checklist annotations in `get-note-markdown` read directly from the Apple Notes SQLite database. This requires **Full Disk Access** for the process running the MCP server.
699
+
700
+ ### How to Grant Full Disk Access
701
+
702
+ 1. Open **System Settings** (or System Preferences on older macOS)
703
+ 2. Go to **Privacy & Security > Full Disk Access**
704
+ 3. Click the **+** button
705
+ 4. Add the application that hosts the MCP server:
706
+ - **Claude Desktop**: Add `/Applications/Claude.app`
707
+ - **Terminal**: Add `/Applications/Utilities/Terminal.app`
708
+ - **VS Code**: Add `/Applications/Visual Studio Code.app`
709
+ - **iTerm**: Add `/Applications/iTerm.app`
710
+ 5. Restart the application after granting access
711
+
712
+ ### Without Full Disk Access
713
+
714
+ All other tools work normally without Full Disk Access. Only checklist state features are affected:
715
+ - `get-checklist-state` will return an error explaining that database access is needed
716
+ - `get-note-markdown` will return plain list items without `[x]`/`[ ]` annotations (graceful fallback)
717
+
718
+ ---
719
+
624
720
  ## Security and Privacy
625
721
 
626
722
  - **Local only** - All operations happen locally via AppleScript. No data is sent to external servers.
@@ -637,8 +733,9 @@ If installed from source, use this configuration:
637
733
  | macOS only | Apple Notes and AppleScript are macOS-specific |
638
734
  | No attachment content | Attachments can be listed but not downloaded via AppleScript |
639
735
  | No pinned notes | Pin status is not exposed via AppleScript |
640
- | No rich formatting | Content is HTML; complex formatting may not render |
736
+ | Limited rich formatting | Use `format: "html"` on create/update for headings, lists, bold, code blocks; some complex formatting may not render |
641
737
  | Title matching | Most operations require exact title matches |
738
+ | Checklist state | Requires Full Disk Access to read done/undone state from the database |
642
739
 
643
740
  ### Backslash Escaping (Important for AI Agents)
644
741
 
@@ -717,3 +814,7 @@ MIT License - see [LICENSE](LICENSE) for details.
717
814
  ## Contributing
718
815
 
719
816
  Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
817
+
818
+ ## Related Projects
819
+
820
+ - [apple-mail-mcp](https://github.com/sweetrb/apple-mail-mcp) - MCP server for Apple Mail
package/build/index.js CHANGED
@@ -25,6 +25,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
25
25
  import { z } from "zod";
26
26
  import { AppleNotesManager } from "./services/appleNotesManager.js";
27
27
  import { getSyncStatus, withSyncAwarenessSync } from "./utils/syncDetection.js";
28
+ import { getChecklistItems, hasFullDiskAccess } from "./utils/checklistParser.js";
28
29
  // Read version from package.json to keep it in sync
29
30
  const require = createRequire(import.meta.url);
30
31
  const { version } = require("../package.json");
@@ -112,9 +113,14 @@ const folderNameSchema = {
112
113
  server.tool("create-note", {
113
114
  title: z.string().min(1, "Title is required"),
114
115
  content: z.string().min(1, "Content is required"),
116
+ format: z
117
+ .enum(["plaintext", "html"])
118
+ .optional()
119
+ .default("plaintext")
120
+ .describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
115
121
  tags: z.array(z.string()).optional().describe("Tags for organization"),
116
- }, withErrorHandling(({ title, content, tags = [] }) => {
117
- const note = notesManager.createNote(title, content, tags);
122
+ }, withErrorHandling(({ title, content, format = "plaintext", tags = [] }) => {
123
+ const note = notesManager.createNote(title, content, tags, undefined, undefined, format);
118
124
  if (!note) {
119
125
  return errorResponse(`Failed to create note "${title}". Check that Notes.app is configured and accessible.`);
120
126
  }
@@ -126,11 +132,18 @@ server.tool("search-notes", {
126
132
  searchContent: z.boolean().optional().describe("Search note content instead of titles"),
127
133
  account: z.string().optional().describe("Account to search in"),
128
134
  folder: z.string().optional().describe("Limit search to a specific folder"),
129
- }, withErrorHandling(({ query, searchContent = false, account, folder }) => {
135
+ modifiedSince: z
136
+ .string()
137
+ .optional()
138
+ .describe("ISO 8601 date string to filter notes modified on or after this date (e.g., '2025-01-01'). Useful for searching only recent notes in large collections."),
139
+ limit: z.number().int().positive().optional().describe("Maximum number of results to return"),
140
+ }, withErrorHandling(({ query, searchContent = false, account, folder, modifiedSince, limit }) => {
130
141
  // Use sync-aware wrapper for this read operation
131
- const { result: notes, syncBefore, syncInterference, } = withSyncAwarenessSync("search-notes", () => notesManager.searchNotes(query, searchContent, account, folder));
142
+ const { result: notes, syncBefore, syncInterference, } = withSyncAwarenessSync("search-notes", () => notesManager.searchNotes(query, searchContent, account, folder, modifiedSince, limit));
132
143
  const searchType = searchContent ? "content" : "titles";
133
144
  const folderInfo = folder ? ` in folder "${folder}"` : "";
145
+ const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
146
+ const limitInfo = limit ? ` (limit: ${limit})` : "";
134
147
  // Build sync warning if needed
135
148
  const syncWarnings = [];
136
149
  if (syncBefore.syncDetected) {
@@ -141,7 +154,7 @@ server.tool("search-notes", {
141
154
  }
142
155
  const syncNote = syncWarnings.length > 0 ? `\n\n${syncWarnings.join(" ")}` : "";
143
156
  if (notes.length === 0) {
144
- return successResponse(`No notes found matching "${query}" in ${searchType}${folderInfo}${syncNote}`);
157
+ return successResponse(`No notes found matching "${query}" in ${searchType}${folderInfo}${dateInfo}${syncNote}`);
145
158
  }
146
159
  // Format each note with ID and folder info, highlighting Recently Deleted
147
160
  const noteList = notes
@@ -156,7 +169,7 @@ server.tool("search-notes", {
156
169
  return ` - ${n.title}${idSuffix}`;
157
170
  })
158
171
  .join("\n");
159
- return successResponse(`Found ${notes.length} notes (searched ${searchType}${folderInfo}):\n${noteList}${syncNote}`);
172
+ return successResponse(`Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):\n${noteList}${syncNote}`);
160
173
  }, "Error searching notes"));
161
174
  // --- get-note-content ---
162
175
  server.tool("get-note-content", {
@@ -244,11 +257,16 @@ server.tool("update-note", {
244
257
  title: z.string().optional().describe("Current note title (use id instead when available)"),
245
258
  newTitle: z.string().optional().describe("New title for the note"),
246
259
  newContent: z.string().min(1, "New content is required"),
260
+ format: z
261
+ .enum(["plaintext", "html"])
262
+ .optional()
263
+ .default("plaintext")
264
+ .describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
247
265
  account: z
248
266
  .string()
249
267
  .optional()
250
268
  .describe("Account containing the note (ignored if id is provided)"),
251
- }, withErrorHandling(({ id, title, newTitle, newContent, account }) => {
269
+ }, withErrorHandling(({ id, title, newTitle, newContent, format = "plaintext", account }) => {
252
270
  // Prefer ID-based update if provided
253
271
  if (id) {
254
272
  // Check for password protection first for better error message
@@ -259,7 +277,7 @@ server.tool("update-note", {
259
277
  if (note.passwordProtected) {
260
278
  return errorResponse(`Note "${note.title}" is password-protected and cannot be updated. Unlock it in Notes.app first.`);
261
279
  }
262
- const success = notesManager.updateNoteById(id, newTitle, newContent);
280
+ const success = notesManager.updateNoteById(id, newTitle, newContent, format);
263
281
  if (!success) {
264
282
  return errorResponse(`Failed to update note "${note.title}"`);
265
283
  }
@@ -282,7 +300,7 @@ server.tool("update-note", {
282
300
  if (note.passwordProtected) {
283
301
  return errorResponse(`Note "${title}" is password-protected and cannot be updated. Unlock it in Notes.app first.`);
284
302
  }
285
- const success = notesManager.updateNote(title, newTitle, newContent, account);
303
+ const success = notesManager.updateNote(title, newTitle, newContent, account, format);
286
304
  if (!success) {
287
305
  return errorResponse(`Failed to update note "${title}"`);
288
306
  }
@@ -377,12 +395,19 @@ server.tool("move-note", {
377
395
  server.tool("list-notes", {
378
396
  account: z.string().optional().describe("Account to list notes from"),
379
397
  folder: z.string().optional().describe("Filter to specific folder"),
380
- }, withErrorHandling(({ account, folder }) => {
398
+ modifiedSince: z
399
+ .string()
400
+ .optional()
401
+ .describe("ISO 8601 date string to filter notes modified on or after this date (e.g., '2025-01-01'). Useful for listing only recent notes in large collections."),
402
+ limit: z.number().int().positive().optional().describe("Maximum number of notes to return"),
403
+ }, withErrorHandling(({ account, folder, modifiedSince, limit }) => {
381
404
  // Use sync-aware wrapper for this read operation
382
- const { result: notes, syncBefore, syncInterference, } = withSyncAwarenessSync("list-notes", () => notesManager.listNotes(account, folder));
405
+ const { result: notes, syncBefore, syncInterference, } = withSyncAwarenessSync("list-notes", () => notesManager.listNotes(account, folder, modifiedSince, limit));
383
406
  // Build context string for the response
384
407
  const location = folder ? ` in folder "${folder}"` : "";
385
408
  const acct = account ? ` (${account})` : "";
409
+ const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
410
+ const limitInfo = limit ? ` (limit: ${limit})` : "";
386
411
  // Build sync warning if needed
387
412
  const syncWarnings = [];
388
413
  if (syncBefore.syncDetected) {
@@ -393,10 +418,10 @@ server.tool("list-notes", {
393
418
  }
394
419
  const syncNote = syncWarnings.length > 0 ? `\n\n${syncWarnings.join(" ")}` : "";
395
420
  if (notes.length === 0) {
396
- return successResponse(`No notes found${location}${acct}${syncNote}`);
421
+ return successResponse(`No notes found${location}${acct}${dateInfo}${syncNote}`);
397
422
  }
398
423
  const noteList = notes.map((t) => ` - ${t}`).join("\n");
399
- return successResponse(`Found ${notes.length} notes${location}${acct}:\n${noteList}${syncNote}`);
424
+ return successResponse(`Found ${notes.length} notes${location}${acct}${dateInfo}${limitInfo}:\n${noteList}${syncNote}`);
400
425
  }, "Error listing notes"));
401
426
  // =============================================================================
402
427
  // Folder Tools
@@ -509,7 +534,12 @@ server.tool("health-check", {}, withErrorHandling(() => {
509
534
  return ` ${icon} ${c.name}: ${c.message}`;
510
535
  })
511
536
  .join("\n");
512
- return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}`);
537
+ // Check Full Disk Access for checklist features
538
+ const fdaAvailable = hasFullDiskAccess();
539
+ const fdaLine = fdaAvailable
540
+ ? " ✓ full_disk_access: Granted (checklist features available)"
541
+ : " ⓘ full_disk_access: Not granted (optional — needed for get-checklist-state and checklist annotations in get-note-markdown). Grant in System Settings > Privacy & Security > Full Disk Access.";
542
+ return successResponse(`${statusIcon} ${statusText}\n\n${checkLines}\n${fdaLine}`);
513
543
  }, "Error running health check"));
514
544
  // --- get-notes-stats ---
515
545
  server.tool("get-notes-stats", {}, withErrorHandling(() => {
@@ -662,6 +692,28 @@ server.tool("get-note-markdown", {
662
692
  }
663
693
  return successResponse(markdown);
664
694
  }, "Error getting note as markdown"));
695
+ // --- get-checklist-state ---
696
+ server.tool("get-checklist-state", {
697
+ id: z.string().min(1, "Note ID is required. Use search-notes to find the note ID first."),
698
+ }, withErrorHandling(({ id }) => {
699
+ // Verify the note exists and is accessible
700
+ const note = notesManager.getNoteById(id);
701
+ if (!note) {
702
+ return errorResponse(`Note with ID "${id}" not found`);
703
+ }
704
+ if (note.passwordProtected) {
705
+ return errorResponse(`Note "${note.title}" is password-protected and cannot be read. Unlock it in Notes.app first.`);
706
+ }
707
+ const result = getChecklistItems(id);
708
+ if (!result.items) {
709
+ return errorResponse(result.message || "Failed to read checklist state.");
710
+ }
711
+ const summary = result.items
712
+ .map((item) => `${item.done ? "[x]" : "[ ]"} ${item.text}`)
713
+ .join("\n");
714
+ const checked = result.items.filter((i) => i.done).length;
715
+ return successResponse(`Checklist for "${note.title}" (${checked}/${result.items.length} done):\n${summary}`);
716
+ }, "Error reading checklist state"));
665
717
  // =============================================================================
666
718
  // Server Startup
667
719
  // =============================================================================