apple-notes-mcp 2.1.1 → 2.1.2

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
@@ -103,11 +103,12 @@ On first use, macOS will ask for permission to automate Notes.app. Click "OK" to
103
103
  | **Delete Notes** | Remove notes (moves to Recently Deleted) |
104
104
  | **Move Notes** | Organize notes into folders (supports nested paths) |
105
105
  | **Folder Management** | Create, list, and delete folders with full hierarchical path support |
106
- | **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account |
106
+ | **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account, including account IDs and default folders |
107
107
  | **Batch Operations** | Delete or move multiple notes at once |
108
108
  | **Checklist State** | Read checklist done/undone state directly from the Notes database (requires Full Disk Access) |
109
109
  | **Export** | Export all notes as JSON or get individual notes as Markdown |
110
110
  | **Attachments** | List attachments, save them to disk, or fetch their bytes as base64 |
111
+ | **Notes.app UI State** | Reveal a note in Notes.app or read the current Notes.app selection |
111
112
  | **Sync Awareness** | Detect iCloud sync in progress, warn about incomplete results |
112
113
  | **Collaboration** | Detect shared notes, warn before modifying |
113
114
  | **Diagnostics** | `health-check` plus a richer `doctor` (reachability, automation permission, accounts, Full Disk Access), sync status, and statistics |
@@ -302,6 +303,19 @@ Retrieves a note using its unique CoreData identifier.
302
303
 
303
304
  ---
304
305
 
306
+ #### `show-note`
307
+
308
+ Reveals a note in Notes.app using its unique CoreData identifier.
309
+
310
+ | Parameter | Type | Required | Description |
311
+ |-----------|------|----------|-------------|
312
+ | `id` | string | Yes | The CoreData URL identifier (e.g., `x-coredata://...`) |
313
+ | `separately` | boolean | No | Open in a separate note window when supported by Notes.app |
314
+
315
+ **Returns:** Confirmation that Notes.app accepted the show command.
316
+
317
+ ---
318
+
305
319
  #### `update-note`
306
320
 
307
321
  Updates an existing note's content and/or title.
@@ -459,6 +473,16 @@ Lists all notes, optionally filtered by folder, date, and limit.
459
473
 
460
474
  ---
461
475
 
476
+ #### `get-selected-notes`
477
+
478
+ Reads the currently selected note(s) from the Notes.app UI.
479
+
480
+ **Parameters:** None
481
+
482
+ **Returns:** Selected note metadata, including IDs for follow-up operations. Returns an empty list when Notes.app has no selected note.
483
+
484
+ ---
485
+
462
486
  ### Folder Operations
463
487
 
464
488
  #### `list-folders`
@@ -474,7 +498,7 @@ Lists all folders in an account with full hierarchical paths.
474
498
  {}
475
499
  ```
476
500
 
477
- **Returns:** List of folder paths. 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`).
501
+ **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`).
478
502
 
479
503
  ---
480
504
 
@@ -533,7 +557,17 @@ Lists all configured Notes accounts.
533
557
  {}
534
558
  ```
535
559
 
536
- **Returns:** List of account names (e.g., "iCloud", "Gmail", "Exchange").
560
+ **Returns:** List of accounts with names, IDs, upgraded state, and default folder metadata.
561
+
562
+ ---
563
+
564
+ #### `get-default-location`
565
+
566
+ Returns the default account and folder Notes.app uses for newly created notes.
567
+
568
+ **Parameters:** None
569
+
570
+ **Returns:** Default account and folder metadata, including IDs and shared state.
537
571
 
538
572
  ---
539
573
 
@@ -631,7 +665,7 @@ Lists attachments in a note.
631
665
  | `title` | string | No | Note title |
632
666
  | `account` | string | No | Account containing the note |
633
667
 
634
- **Returns:** List of attachments with names and content types.
668
+ **Returns:** List of attachments with IDs, names, content identifiers, URLs when available, created/modified dates, and shared state.
635
669
 
636
670
  ---
637
671
 
package/build/index.js CHANGED
@@ -260,6 +260,20 @@ server.tool("get-note-details", "Use when: you have a note title (not an id) and
260
260
  };
261
261
  return successResponse(JSON.stringify(metadata, null, 2), metadata);
262
262
  }, "Error retrieving note details"));
263
+ // --- show-note ---
264
+ server.tool("show-note", "Use when: the user wants to reveal a known note in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need note content (get-note-content) or metadata (get-note-by-id).\nNote: this opens or focuses the Notes UI.", {
265
+ id: z.string().min(1, "Note ID is required"),
266
+ separately: z
267
+ .boolean()
268
+ .optional()
269
+ .describe("Open in a separate note window when supported by Notes.app"),
270
+ }, withErrorHandling(({ id, separately = false }) => {
271
+ const success = notesManager.showNoteById(id, separately);
272
+ if (!success) {
273
+ return errorResponse(`Failed to show note with ID "${id}"`);
274
+ }
275
+ return successResponse(`Shown note with ID "${id}" in Notes.app`, { id, separately });
276
+ }, "Error showing note"));
263
277
  // --- update-note ---
264
278
  server.tool("update-note", "Use when: changing the title and/or replacing the body of an existing note, by id (preferred) or title.\nReturns: confirmation; warns when the note is shared.\nDo not use when: creating a new note (create-note).\nSafety: newContent REPLACES the entire body — it does not append. Read the note first if you need to preserve existing text. Edits to shared notes are immediately visible to all collaborators.", {
265
279
  id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
@@ -440,6 +454,18 @@ server.tool("list-notes", "Use when: enumerating notes in an account or folder;
440
454
  const noteList = notes.map((t) => ` - ${t}`).join("\n");
441
455
  return successResponse(`Found ${notes.length} notes${location}${acct}${dateInfo}${limitInfo}:\n${noteList}${syncNote}`, { notes, count: notes.length });
442
456
  }, "Error listing notes"));
457
+ // --- get-selected-notes ---
458
+ server.tool("get-selected-notes", "Use when: the user asks what note(s) are currently selected in Notes.app.\nReturns: selected note metadata with ids for follow-up operations.\nDo not use when: searching all notes (search-notes) or listing a folder (list-notes).\nNote: reads Notes.app UI selection; it may be empty if Notes is closed or no note is selected.", {}, withErrorHandling(() => {
459
+ const notes = notesManager.getSelectedNotes();
460
+ if (notes.length === 0) {
461
+ return successResponse("No notes are currently selected in Notes.app", {
462
+ notes: [],
463
+ count: 0,
464
+ });
465
+ }
466
+ const noteList = notes.map((n) => ` - ${n.title} [id: ${n.id}]`).join("\n");
467
+ return successResponse(`Selected note(s):\n${noteList}`, { notes, count: notes.length });
468
+ }, "Error getting selected notes"));
443
469
  // =============================================================================
444
470
  // Folder Tools
445
471
  // =============================================================================
@@ -499,12 +525,25 @@ server.tool("list-accounts", "Use when: discovering which Notes accounts exist (
499
525
  if (accounts.length === 0) {
500
526
  return successResponse("No Notes accounts found", { accounts: [], count: 0 });
501
527
  }
502
- const accountList = accounts.map((a) => ` - ${a.name}`).join("\n");
528
+ const accountList = accounts
529
+ .map((a) => {
530
+ const defaultFolder = a.defaultFolder ? ` (default folder: ${a.defaultFolder})` : "";
531
+ const upgraded = a.upgraded === undefined ? "" : `, upgraded: ${a.upgraded ? "yes" : "no"}`;
532
+ return ` - ${a.name}${defaultFolder}${upgraded}`;
533
+ })
534
+ .join("\n");
503
535
  return successResponse(`Found ${accounts.length} accounts:\n${accountList}`, {
504
536
  accounts,
505
537
  count: accounts.length,
506
538
  });
507
539
  }, "Error listing accounts"));
540
+ // --- get-default-location ---
541
+ server.tool("get-default-location", "Use when: discovering where Notes.app will create new notes by default.\nReturns: default account and default folder metadata.\nDo not use when: you already have an explicit account/folder target.", {}, withErrorHandling(() => {
542
+ const location = notesManager.getDefaultLocation();
543
+ const message = `Default account: ${location.account.name} [id: ${location.account.id}]\n` +
544
+ `Default folder: ${location.folder.name} [id: ${location.folder.id}]`;
545
+ return successResponse(message, { ...location });
546
+ }, "Error getting default Notes location"));
508
547
  // =============================================================================
509
548
  // Collaboration Tools
510
549
  // =============================================================================
@@ -1207,25 +1207,23 @@ export class AppleNotesManager {
1207
1207
  */
1208
1208
  listFolders(account) {
1209
1209
  const targetAccount = this.resolveAccount(account);
1210
- // Get each folder's ID, name, and parent ID in a single AppleScript call.
1211
- // Each line: "id\tname\tparentId" for subfolders, "id\tname" for top-level.
1210
+ // Get each folder's ID, name, parent ID, and shared state in a single AppleScript call.
1212
1211
  // Using IDs enables correct tree building even with duplicate folder names.
1213
1212
  const listCommand = `
1214
- set folderList to ""
1213
+ set folderList to {}
1215
1214
  set allFolders to every folder
1216
1215
  repeat with f in allFolders
1217
1216
  set fRef to contents of f
1218
1217
  set cRef to container of fRef
1219
- if folderList is not "" then
1220
- set folderList to folderList & linefeed
1221
- end if
1218
+ set parentId to ""
1222
1219
  if class of cRef is folder then
1223
- set folderList to folderList & (id of fRef) & tab & (name of fRef) & tab & (id of cRef)
1224
- else
1225
- set folderList to folderList & (id of fRef) & tab & (name of fRef)
1220
+ set parentId to id of cRef
1226
1221
  end if
1222
+ set sharedFlag to shared of fRef as text
1223
+ set end of folderList to (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & parentId & ${AS_FIELD_SEP} & sharedFlag
1227
1224
  end repeat
1228
- return folderList
1225
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1226
+ return folderList as text
1229
1227
  `;
1230
1228
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1231
1229
  const result = executeAppleScript(script);
@@ -1235,13 +1233,14 @@ export class AppleNotesManager {
1235
1233
  if (!result.output.trim()) {
1236
1234
  return [];
1237
1235
  }
1238
- // Parse "id\tname[\tparentId]" lines
1239
- const entries = result.output.split("\n").map((line) => {
1240
- const parts = line.split("\t");
1236
+ const recordSeparator = result.output.includes(RECORD_SEP) ? RECORD_SEP : "\n";
1237
+ const entries = result.output.split(recordSeparator).map((line) => {
1238
+ const parts = line.includes(FIELD_SEP) ? line.split(FIELD_SEP) : line.split("\t");
1241
1239
  return {
1242
1240
  id: (parts[0] || "").trim(),
1243
1241
  name: (parts[1] || "").trim(),
1244
1242
  parentId: (parts[2] || "").trim(),
1243
+ shared: (parts[3] || "").trim().toLowerCase() === "true",
1245
1244
  };
1246
1245
  });
1247
1246
  // Build an ID-to-entry map for efficient parent lookups
@@ -1264,6 +1263,7 @@ export class AppleNotesManager {
1264
1263
  id: entry.id,
1265
1264
  name: buildPath(entry),
1266
1265
  account: targetAccount,
1266
+ shared: entry.shared,
1267
1267
  }));
1268
1268
  }
1269
1269
  /**
@@ -1455,10 +1455,22 @@ export class AppleNotesManager {
1455
1455
  * @returns Array of Account objects
1456
1456
  */
1457
1457
  listAccounts() {
1458
- // Coerce the name list to text with a control-char record separator so an
1459
- // account name containing a comma can't split into phantom accounts (#18).
1458
+ // Coerce account records to text with control-char delimiters so names
1459
+ // containing commas or tabs can't split into phantom accounts (#18).
1460
1460
  const listCommand = `
1461
- set resultList to name of accounts
1461
+ set resultList to {}
1462
+ repeat with a in accounts
1463
+ set aRef to contents of a
1464
+ set defaultFolderId to ""
1465
+ set defaultFolderName to ""
1466
+ try
1467
+ set fRef to default folder of aRef
1468
+ set defaultFolderId to id of fRef
1469
+ set defaultFolderName to name of fRef
1470
+ end try
1471
+ set upgradedFlag to upgraded of aRef as text
1472
+ set end of resultList to (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & upgradedFlag & ${AS_FIELD_SEP} & defaultFolderId & ${AS_FIELD_SEP} & defaultFolderName
1473
+ end repeat
1462
1474
  set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1463
1475
  return resultList as text
1464
1476
  `;
@@ -1467,11 +1479,130 @@ export class AppleNotesManager {
1467
1479
  if (!result.success) {
1468
1480
  throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
1469
1481
  }
1470
- const names = result.output
1482
+ return result.output
1471
1483
  .split(RECORD_SEP)
1472
1484
  .map((s) => s.trim())
1473
- .filter((s) => s.length > 0);
1474
- return names.map((name) => ({ name }));
1485
+ .filter((s) => s.length > 0)
1486
+ .map((item) => {
1487
+ const parts = item.split(FIELD_SEP);
1488
+ if (parts.length === 1) {
1489
+ return { name: parts[0].trim() };
1490
+ }
1491
+ return {
1492
+ id: (parts[0] || "").trim(),
1493
+ name: (parts[1] || "").trim(),
1494
+ upgraded: (parts[2] || "").trim().toLowerCase() === "true",
1495
+ defaultFolderId: (parts[3] || "").trim() || undefined,
1496
+ defaultFolder: (parts[4] || "").trim() || undefined,
1497
+ };
1498
+ });
1499
+ }
1500
+ /**
1501
+ * Gets the default account and folder used by Notes.app for new notes.
1502
+ *
1503
+ * @returns Default account and folder metadata
1504
+ */
1505
+ getDefaultLocation() {
1506
+ const command = `
1507
+ set aRef to default account
1508
+ set fRef to default folder of aRef
1509
+ return (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & (upgraded of aRef as text) & ${AS_FIELD_SEP} & (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & (shared of fRef as text)
1510
+ `;
1511
+ const result = executeAppleScript(buildAppLevelScript(command));
1512
+ if (!result.success) {
1513
+ throw new Error(`Failed to get default Notes location: ${result.error ?? "unknown error"}`);
1514
+ }
1515
+ const parts = result.output.split(FIELD_SEP);
1516
+ if (parts.length < 6) {
1517
+ throw new Error(`Failed to parse default Notes location: ${result.output}`);
1518
+ }
1519
+ const accountName = (parts[1] || "").trim();
1520
+ return {
1521
+ account: {
1522
+ id: (parts[0] || "").trim(),
1523
+ name: accountName,
1524
+ upgraded: (parts[2] || "").trim().toLowerCase() === "true",
1525
+ defaultFolderId: (parts[3] || "").trim(),
1526
+ defaultFolder: (parts[4] || "").trim(),
1527
+ },
1528
+ folder: {
1529
+ id: (parts[3] || "").trim(),
1530
+ name: (parts[4] || "").trim(),
1531
+ account: accountName,
1532
+ shared: (parts[5] || "").trim().toLowerCase() === "true",
1533
+ },
1534
+ };
1535
+ }
1536
+ /**
1537
+ * Lists the currently selected Notes in the Notes.app UI.
1538
+ *
1539
+ * @returns Array of selected notes, or an empty array when nothing is selected
1540
+ */
1541
+ getSelectedNotes() {
1542
+ const command = `
1543
+ set selectedNotes to selection
1544
+ set noteList to {}
1545
+ repeat with n in selectedNotes
1546
+ set nRef to contents of n
1547
+ set createdDate to creation date of nRef
1548
+ set modifiedDate to modification date of nRef
1549
+ set createdParts to ${asDatePartsExpr("createdDate")}
1550
+ set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1551
+ set folderName to ""
1552
+ set accountName to ""
1553
+ try
1554
+ set fRef to container of nRef
1555
+ set folderName to name of fRef
1556
+ set aRef to container of fRef
1557
+ set accountName to name of aRef
1558
+ end try
1559
+ set end of noteList to (id of nRef) & ${AS_FIELD_SEP} & (name of nRef) & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & (shared of nRef as text) & ${AS_FIELD_SEP} & (password protected of nRef as text) & ${AS_FIELD_SEP} & folderName & ${AS_FIELD_SEP} & accountName
1560
+ end repeat
1561
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1562
+ return noteList as text
1563
+ `;
1564
+ const result = executeAppleScript(buildAppLevelScript(command));
1565
+ if (!result.success) {
1566
+ throw new Error(`Failed to get selected notes: ${result.error ?? "unknown error"}`);
1567
+ }
1568
+ if (!result.output.trim()) {
1569
+ return [];
1570
+ }
1571
+ return result.output
1572
+ .split(RECORD_SEP)
1573
+ .filter((s) => s.trim())
1574
+ .map((item) => {
1575
+ const parts = item.split(FIELD_SEP);
1576
+ return {
1577
+ id: (parts[0] || "").trim(),
1578
+ title: (parts[1] || "").trim(),
1579
+ content: "",
1580
+ tags: [],
1581
+ created: parseAppleScriptDate((parts[2] || "").trim()),
1582
+ modified: parseAppleScriptDate((parts[3] || "").trim()),
1583
+ shared: (parts[4] || "").trim().toLowerCase() === "true",
1584
+ passwordProtected: (parts[5] || "").trim().toLowerCase() === "true",
1585
+ folder: (parts[6] || "").trim() || undefined,
1586
+ account: (parts[7] || "").trim() || undefined,
1587
+ };
1588
+ });
1589
+ }
1590
+ /**
1591
+ * Reveals a note in the Notes.app UI by ID.
1592
+ *
1593
+ * @param id - CoreData URL identifier for the note
1594
+ * @param separately - Whether to open the note in a separate window
1595
+ * @returns true if Notes.app accepted the show command
1596
+ */
1597
+ showNoteById(id, separately = false) {
1598
+ const safeId = sanitizeId(id);
1599
+ const separatelyClause = separately ? " separately true" : "";
1600
+ const result = executeAppleScript(buildAppLevelScript(`show note id "${safeId}"${separatelyClause}`));
1601
+ if (!result.success) {
1602
+ console.error(`Failed to show note with ID "${id}":`, result.error);
1603
+ return false;
1604
+ }
1605
+ return true;
1475
1606
  }
1476
1607
  // ===========================================================================
1477
1608
  // Health Check
@@ -1738,8 +1869,17 @@ export class AppleNotesManager {
1738
1869
  repeat with a in attachments of theNote
1739
1870
  set attachId to id of a
1740
1871
  set attachName to name of a
1741
- set attachType to content identifier of a
1742
- set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
1872
+ set attachContentId to content identifier of a
1873
+ set attachUrl to ""
1874
+ try
1875
+ set attachUrl to URL of a as text
1876
+ end try
1877
+ set createdDate to creation date of a
1878
+ set modifiedDate to modification date of a
1879
+ set createdParts to ${asDatePartsExpr("createdDate")}
1880
+ set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1881
+ set sharedFlag to shared of a as text
1882
+ set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
1743
1883
  end repeat
1744
1884
  set output to ""
1745
1885
  repeat with item in attachmentList
@@ -1765,6 +1905,11 @@ export class AppleNotesManager {
1765
1905
  id: parts[0].trim(),
1766
1906
  name: parts[1].trim(),
1767
1907
  contentType: parts[2].trim(),
1908
+ contentId: parts[2].trim() || undefined,
1909
+ url: parts[3]?.trim() || undefined,
1910
+ created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
1911
+ modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
1912
+ shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
1768
1913
  });
1769
1914
  }
1770
1915
  }
@@ -1784,13 +1929,22 @@ export class AppleNotesManager {
1784
1929
  tell application "Notes"
1785
1930
  tell account "${targetAccount}"
1786
1931
  set theNote to note "${safeTitle}"
1787
- set attachmentList to {}
1788
- repeat with a in attachments of theNote
1789
- set attachId to id of a
1790
- set attachName to name of a
1791
- set attachType to content identifier of a
1792
- set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
1793
- end repeat
1932
+ set attachmentList to {}
1933
+ repeat with a in attachments of theNote
1934
+ set attachId to id of a
1935
+ set attachName to name of a
1936
+ set attachContentId to content identifier of a
1937
+ set attachUrl to ""
1938
+ try
1939
+ set attachUrl to URL of a as text
1940
+ end try
1941
+ set createdDate to creation date of a
1942
+ set modifiedDate to modification date of a
1943
+ set createdParts to ${asDatePartsExpr("createdDate")}
1944
+ set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1945
+ set sharedFlag to shared of a as text
1946
+ set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
1947
+ end repeat
1794
1948
  set output to ""
1795
1949
  repeat with item in attachmentList
1796
1950
  set output to output & item & ${AS_RECORD_SEP}
@@ -1816,6 +1970,11 @@ export class AppleNotesManager {
1816
1970
  id: parts[0].trim(),
1817
1971
  name: parts[1].trim(),
1818
1972
  contentType: parts[2].trim(),
1973
+ contentId: parts[2].trim() || undefined,
1974
+ url: parts[3]?.trim() || undefined,
1975
+ created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
1976
+ modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
1977
+ shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
1819
1978
  });
1820
1979
  }
1821
1980
  }
@@ -1145,7 +1145,11 @@ describe("AppleNotesManager", () => {
1145
1145
  it("returns array of Folder objects with paths", () => {
1146
1146
  mockExecuteAppleScript.mockReturnValue({
1147
1147
  success: true,
1148
- output: "id1\tNotes\nid2\tArchive\nid3\tWork",
1148
+ output: [
1149
+ ["id1", "Notes", "", "false"].join(F),
1150
+ ["id2", "Archive", "", "false"].join(F),
1151
+ ["id3", "Work", "", "true"].join(F),
1152
+ ].join(R),
1149
1153
  });
1150
1154
  const folders = manager.listFolders();
1151
1155
  expect(folders).toHaveLength(3);
@@ -1153,11 +1157,17 @@ describe("AppleNotesManager", () => {
1153
1157
  expect(folders[1].name).toBe("Archive");
1154
1158
  expect(folders[2].name).toBe("Work");
1155
1159
  expect(folders[0].id).toBe("id1");
1160
+ expect(folders[2].shared).toBe(true);
1156
1161
  });
1157
1162
  it("includes parent folder in path", () => {
1158
1163
  mockExecuteAppleScript.mockReturnValue({
1159
1164
  success: true,
1160
- output: "id1\tDev\nid2\tAccessibility\tid1\nid3\tWork\nid4\tClients\tid3",
1165
+ output: [
1166
+ ["id1", "Dev", "", "false"].join(F),
1167
+ ["id2", "Accessibility", "id1", "false"].join(F),
1168
+ ["id3", "Work", "", "false"].join(F),
1169
+ ["id4", "Clients", "id3", "false"].join(F),
1170
+ ].join(R),
1161
1171
  });
1162
1172
  const folders = manager.listFolders();
1163
1173
  expect(folders).toHaveLength(4);
@@ -1169,7 +1179,13 @@ describe("AppleNotesManager", () => {
1169
1179
  it("disambiguates duplicate folder names using IDs", () => {
1170
1180
  mockExecuteAppleScript.mockReturnValue({
1171
1181
  success: true,
1172
- output: "id1\tFinance\nid2\tArchive\tid1\nid3\tTravel\nid4\tTrips\tid3\nid5\tArchive\tid4",
1182
+ output: [
1183
+ ["id1", "Finance", "", "false"].join(F),
1184
+ ["id2", "Archive", "id1", "false"].join(F),
1185
+ ["id3", "Travel", "", "false"].join(F),
1186
+ ["id4", "Trips", "id3", "false"].join(F),
1187
+ ["id5", "Archive", "id4", "false"].join(F),
1188
+ ].join(R),
1173
1189
  });
1174
1190
  const folders = manager.listFolders();
1175
1191
  expect(folders).toHaveLength(5);
@@ -1179,17 +1195,31 @@ describe("AppleNotesManager", () => {
1179
1195
  it("escapes slashes in folder names", () => {
1180
1196
  mockExecuteAppleScript.mockReturnValue({
1181
1197
  success: true,
1182
- output: "id1\tTravel\nid2\tSpain/Portugal 2023\tid1",
1198
+ output: [
1199
+ ["id1", "Travel", "", "false"].join(F),
1200
+ ["id2", "Spain/Portugal 2023", "id1", "false"].join(F),
1201
+ ].join(R),
1183
1202
  });
1184
1203
  const folders = manager.listFolders();
1185
1204
  expect(folders).toHaveLength(2);
1186
1205
  expect(folders[0].name).toBe("Travel");
1187
1206
  expect(folders[1].name).toBe("Travel/Spain\\/Portugal 2023");
1188
1207
  });
1208
+ it("parses legacy tab/newline output (backward compat)", () => {
1209
+ mockExecuteAppleScript.mockReturnValue({
1210
+ success: true,
1211
+ output: "id1\tNotes\nid2\tArchive\tid1",
1212
+ });
1213
+ const folders = manager.listFolders();
1214
+ expect(folders).toHaveLength(2);
1215
+ expect(folders[0].name).toBe("Notes");
1216
+ expect(folders[1].name).toBe("Notes/Archive");
1217
+ expect(folders[1].shared).toBe(false);
1218
+ });
1189
1219
  it("includes account in Folder objects", () => {
1190
1220
  mockExecuteAppleScript.mockReturnValue({
1191
1221
  success: true,
1192
- output: "id1\tNotes",
1222
+ output: ["id1", "Notes", "", "false"].join(F),
1193
1223
  });
1194
1224
  const folders = manager.listFolders("Gmail");
1195
1225
  expect(folders[0].account).toBe("Gmail");
@@ -1420,13 +1450,41 @@ describe("AppleNotesManager", () => {
1420
1450
  it("returns array of Account objects", () => {
1421
1451
  mockExecuteAppleScript.mockReturnValue({
1422
1452
  success: true,
1423
- output: ["iCloud", "Gmail", "Exchange"].join(R),
1453
+ output: [
1454
+ ["acc1", "iCloud", "true", "folder1", "Notes"].join(F),
1455
+ ["acc2", "Gmail", "false", "folder2", "Inbox"].join(F),
1456
+ ["acc3", "Exchange", "false", "", ""].join(F),
1457
+ ].join(R),
1424
1458
  });
1425
1459
  const accounts = manager.listAccounts();
1426
1460
  expect(accounts).toHaveLength(3);
1427
1461
  expect(accounts[0].name).toBe("iCloud");
1428
1462
  expect(accounts[1].name).toBe("Gmail");
1429
1463
  expect(accounts[2].name).toBe("Exchange");
1464
+ expect(accounts[0].id).toBe("acc1");
1465
+ expect(accounts[0].upgraded).toBe(true);
1466
+ expect(accounts[0].defaultFolder).toBe("Notes");
1467
+ });
1468
+ it("parses legacy plain-name output (backward compat)", () => {
1469
+ mockExecuteAppleScript.mockReturnValue({
1470
+ success: true,
1471
+ output: ["iCloud", "Gmail"].join(R),
1472
+ });
1473
+ const accounts = manager.listAccounts();
1474
+ expect(accounts).toHaveLength(2);
1475
+ expect(accounts[0]).toEqual({ name: "iCloud" });
1476
+ expect(accounts[1]).toEqual({ name: "Gmail" });
1477
+ });
1478
+ it("handles account records with empty fields", () => {
1479
+ mockExecuteAppleScript.mockReturnValue({
1480
+ success: true,
1481
+ output: ["", "", "", "", ""].join(F),
1482
+ });
1483
+ const accounts = manager.listAccounts();
1484
+ expect(accounts).toHaveLength(1);
1485
+ expect(accounts[0].name).toBe("");
1486
+ expect(accounts[0].upgraded).toBe(false);
1487
+ expect(accounts[0].defaultFolderId).toBeUndefined();
1430
1488
  });
1431
1489
  it("throws on failure rather than returning empty (#19)", () => {
1432
1490
  mockExecuteAppleScript.mockReturnValue({
@@ -1437,6 +1495,126 @@ describe("AppleNotesManager", () => {
1437
1495
  expect(() => manager.listAccounts()).toThrow(/Notes.app not available/);
1438
1496
  });
1439
1497
  });
1498
+ describe("getDefaultLocation", () => {
1499
+ it("returns default account and folder metadata", () => {
1500
+ mockExecuteAppleScript.mockReturnValue({
1501
+ success: true,
1502
+ output: ["acc1", "iCloud", "true", "folder1", "Notes", "false"].join(F),
1503
+ });
1504
+ const location = manager.getDefaultLocation();
1505
+ expect(location.account).toMatchObject({
1506
+ id: "acc1",
1507
+ name: "iCloud",
1508
+ upgraded: true,
1509
+ defaultFolderId: "folder1",
1510
+ defaultFolder: "Notes",
1511
+ });
1512
+ expect(location.folder).toMatchObject({
1513
+ id: "folder1",
1514
+ name: "Notes",
1515
+ account: "iCloud",
1516
+ shared: false,
1517
+ });
1518
+ });
1519
+ it("throws when default location output cannot be parsed", () => {
1520
+ mockExecuteAppleScript.mockReturnValue({
1521
+ success: true,
1522
+ output: "bad-output",
1523
+ });
1524
+ expect(() => manager.getDefaultLocation()).toThrow(/parse default Notes location/);
1525
+ });
1526
+ it("throws when AppleScript fails", () => {
1527
+ mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "boom" });
1528
+ expect(() => manager.getDefaultLocation()).toThrow(/Failed to get default Notes location/);
1529
+ });
1530
+ it("handles empty fields in default location output", () => {
1531
+ mockExecuteAppleScript.mockReturnValue({
1532
+ success: true,
1533
+ output: ["", "", "", "", "", ""].join(F),
1534
+ });
1535
+ const location = manager.getDefaultLocation();
1536
+ expect(location.account.name).toBe("");
1537
+ expect(location.account.upgraded).toBe(false);
1538
+ expect(location.folder.id).toBe("");
1539
+ expect(location.folder.shared).toBe(false);
1540
+ });
1541
+ });
1542
+ describe("getSelectedNotes", () => {
1543
+ it("returns selected note metadata", () => {
1544
+ mockExecuteAppleScript.mockReturnValue({
1545
+ success: true,
1546
+ output: [
1547
+ [
1548
+ "x-coredata://ABC/ICNote/p1",
1549
+ "Selected Note",
1550
+ "2026-6-22-14-30-0",
1551
+ "2026-6-22-14-35-0",
1552
+ "false",
1553
+ "false",
1554
+ "Notes",
1555
+ "iCloud",
1556
+ ].join(F),
1557
+ ].join(R),
1558
+ });
1559
+ const notes = manager.getSelectedNotes();
1560
+ expect(notes).toHaveLength(1);
1561
+ expect(notes[0]).toMatchObject({
1562
+ id: "x-coredata://ABC/ICNote/p1",
1563
+ title: "Selected Note",
1564
+ shared: false,
1565
+ passwordProtected: false,
1566
+ folder: "Notes",
1567
+ account: "iCloud",
1568
+ });
1569
+ expect(notes[0].created.getFullYear()).toBe(2026);
1570
+ });
1571
+ it("returns an empty array when no note is selected", () => {
1572
+ mockExecuteAppleScript.mockReturnValue({
1573
+ success: true,
1574
+ output: "",
1575
+ });
1576
+ expect(manager.getSelectedNotes()).toEqual([]);
1577
+ });
1578
+ it("throws when AppleScript fails", () => {
1579
+ mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "boom" });
1580
+ expect(() => manager.getSelectedNotes()).toThrow(/Failed to get selected notes/);
1581
+ });
1582
+ it("handles selected notes with empty optional fields", () => {
1583
+ mockExecuteAppleScript.mockReturnValue({
1584
+ success: true,
1585
+ output: ["", "", "", "", "", "", "", ""].join(F),
1586
+ });
1587
+ const notes = manager.getSelectedNotes();
1588
+ expect(notes).toHaveLength(1);
1589
+ expect(notes[0].id).toBe("");
1590
+ expect(notes[0].shared).toBe(false);
1591
+ expect(notes[0].passwordProtected).toBe(false);
1592
+ expect(notes[0].folder).toBeUndefined();
1593
+ expect(notes[0].account).toBeUndefined();
1594
+ });
1595
+ });
1596
+ describe("showNoteById", () => {
1597
+ it("shows a note by id", () => {
1598
+ mockExecuteAppleScript.mockReturnValue({
1599
+ success: true,
1600
+ output: "",
1601
+ });
1602
+ expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(true);
1603
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show note id "x-coredata://ABC/ICNote/p1"'));
1604
+ });
1605
+ it("can request a separate window", () => {
1606
+ mockExecuteAppleScript.mockReturnValue({
1607
+ success: true,
1608
+ output: "",
1609
+ });
1610
+ manager.showNoteById("x-coredata://ABC/ICNote/p1", true);
1611
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
1612
+ });
1613
+ it("returns false when Notes.app rejects the show command", () => {
1614
+ mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "no such note" });
1615
+ expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(false);
1616
+ });
1617
+ });
1440
1618
  // ---------------------------------------------------------------------------
1441
1619
  // Health Check
1442
1620
  // ---------------------------------------------------------------------------
@@ -1620,16 +1798,45 @@ describe("AppleNotesManager", () => {
1620
1798
  });
1621
1799
  const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1622
1800
  expect(attachments).toHaveLength(2);
1623
- expect(attachments[0]).toEqual({
1801
+ expect(attachments[0]).toMatchObject({
1624
1802
  id: "x-coredata://ABC/ICAttachment/p1",
1625
1803
  name: "photo.jpg",
1626
1804
  contentType: "public.jpeg",
1805
+ contentId: "public.jpeg",
1627
1806
  });
1628
- expect(attachments[1]).toEqual({
1807
+ expect(attachments[1]).toMatchObject({
1629
1808
  id: "x-coredata://ABC/ICAttachment/p2",
1630
1809
  name: "document.pdf",
1631
1810
  contentType: "com.adobe.pdf",
1811
+ contentId: "com.adobe.pdf",
1812
+ });
1813
+ });
1814
+ it("parses richer attachment metadata when present", () => {
1815
+ mockExecuteAppleScript.mockReturnValueOnce({
1816
+ success: true,
1817
+ output: [
1818
+ [
1819
+ "x-coredata://ABC/ICAttachment/p1",
1820
+ "site.webloc",
1821
+ "cid:123",
1822
+ "https://example.com",
1823
+ "2026-6-22-10-0-0",
1824
+ "2026-6-22-11-0-0",
1825
+ "true",
1826
+ ].join(F),
1827
+ ].join(R),
1828
+ });
1829
+ const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1830
+ expect(attachments[0]).toMatchObject({
1831
+ id: "x-coredata://ABC/ICAttachment/p1",
1832
+ name: "site.webloc",
1833
+ contentType: "cid:123",
1834
+ contentId: "cid:123",
1835
+ url: "https://example.com",
1836
+ shared: true,
1632
1837
  });
1838
+ expect(attachments[0].created?.getFullYear()).toBe(2026);
1839
+ expect(attachments[0].modified?.getHours()).toBe(11);
1633
1840
  });
1634
1841
  it("returns empty array when note has no attachments", () => {
1635
1842
  mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
@@ -1659,10 +1866,11 @@ describe("AppleNotesManager", () => {
1659
1866
  });
1660
1867
  const attachments = manager.listAttachments("My Note");
1661
1868
  expect(attachments).toHaveLength(1);
1662
- expect(attachments[0]).toEqual({
1869
+ expect(attachments[0]).toMatchObject({
1663
1870
  id: "attach-id",
1664
1871
  name: "image.png",
1665
1872
  contentType: "public.png",
1873
+ contentId: "public.png",
1666
1874
  });
1667
1875
  });
1668
1876
  it("uses specified account", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",