apple-notes-mcp 2.5.4 → 2.5.6

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
@@ -431,7 +431,7 @@ Deletes a note (moves to Recently Deleted in Notes.app).
431
431
 
432
432
  #### `move-note`
433
433
 
434
- Moves a note to a different folder.
434
+ 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).
435
435
 
436
436
  | Parameter | Type | Required | Description |
437
437
  |-----------|------|----------|-------------|
@@ -942,6 +942,7 @@ All configuration is optional — the server works out of the box. Override beha
942
942
  | Variable | Default | Description |
943
943
  |----------|---------|-------------|
944
944
  | `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. |
945
+ | `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. |
945
946
  | `APPLE_NOTES_MCP_CONFIG_FILE` | `~/Library/Application Support/apple-notes-mcp/config.json` | Path to the JSON config file (see below). |
946
947
  | `DEBUG` / `VERBOSE` | unset | Set either to enable verbose diagnostic logging to stderr. |
947
948
 
package/build/index.js CHANGED
@@ -89,21 +89,45 @@ function withErrorHandling(handler, errorPrefix) {
89
89
  };
90
90
  }
91
91
  // =============================================================================
92
+ // Input Bounds
93
+ // =============================================================================
94
+ /**
95
+ * Upper bounds on string/array inputs (#validation). Zod's `.min(1)` rejected
96
+ * empty input but nothing capped the maximum, so a caller could pass an
97
+ * arbitrarily large string/array straight through to AppleScript. These mirror
98
+ * the limits the AppleNotesManager already enforces internally (title 2000,
99
+ * content 5 MB, folder path 1000, account 200) and add sane caps for the rest,
100
+ * so oversized input is rejected at the schema boundary with a clear message.
101
+ */
102
+ const MAX = {
103
+ TITLE: 2000,
104
+ CONTENT: 5 * 1024 * 1024,
105
+ FOLDER: 1000,
106
+ ACCOUNT: 200,
107
+ QUERY: 2000,
108
+ ID: 2000,
109
+ SAVE_PATH: 4096,
110
+ ATTACHMENT_ID: 2000,
111
+ TAG: 200,
112
+ TAGS: 100,
113
+ BATCH_IDS: 500,
114
+ };
115
+ // =============================================================================
92
116
  // Schema Definitions
93
117
  // =============================================================================
94
118
  /**
95
119
  * Common schema for operations requiring a note title.
96
120
  */
97
121
  const noteTitleSchema = {
98
- title: z.string().min(1, "Note title is required"),
99
- account: z.string().optional().describe("Account name (defaults to iCloud)"),
122
+ title: z.string().min(1, "Note title is required").max(MAX.TITLE),
123
+ account: z.string().max(MAX.ACCOUNT).optional().describe("Account name (defaults to iCloud)"),
100
124
  };
101
125
  /**
102
126
  * Common schema for operations requiring a folder name.
103
127
  */
104
128
  const folderNameSchema = {
105
- name: z.string().min(1, "Folder name is required"),
106
- account: z.string().optional().describe("Account name (defaults to iCloud)"),
129
+ name: z.string().min(1, "Folder name is required").max(MAX.FOLDER),
130
+ account: z.string().max(MAX.ACCOUNT).optional().describe("Account name (defaults to iCloud)"),
107
131
  };
108
132
  // =============================================================================
109
133
  // Note Tools
@@ -112,22 +136,28 @@ const folderNameSchema = {
112
136
  server.registerTool("create-note", {
113
137
  description: "Use when: the user wants to create a brand-new Apple Note.\nReturns: the new note's title and id — reuse the id for follow-up reads/edits.\nDo not use when: editing an existing note (use update-note).\nNote: the title is prepended as an <h1>; true Apple Notes checklists cannot be created via AppleScript (see the content field).",
114
138
  inputSchema: {
115
- title: z.string().min(1, "Title is required"),
139
+ title: z.string().min(1, "Title is required").max(MAX.TITLE),
116
140
  content: z
117
141
  .string()
118
142
  .min(1, "Content is required")
143
+ .max(MAX.CONTENT)
119
144
  .describe('Note body. AppleScript cannot create true Apple Notes checklists — `<input type="checkbox">`, checklist CSS classes, and markdown `- [ ]` lines do not render as checkable items. To produce a checklist, create the note with a plain `<ul>` or `- ` list and convert it in Notes.app with ⇧⌘L.'),
120
145
  format: z
121
146
  .enum(["plaintext", "html"])
122
147
  .optional()
123
148
  .default("plaintext")
124
149
  .describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
125
- tags: z.array(z.string()).optional().describe("Tags for organization"),
150
+ tags: z
151
+ .array(z.string().max(MAX.TAG))
152
+ .max(MAX.TAGS)
153
+ .optional()
154
+ .describe("Returned-only metadata — NOT written to Notes.app. Apple Notes tags can't be set via AppleScript, so any values passed here are echoed back in the response but do not appear on the created note. Use #hashtags inside the content body instead (Notes.app turns those into real tags)."),
126
155
  folder: z
127
156
  .string()
157
+ .max(MAX.FOLDER)
128
158
  .optional()
129
159
  .describe("Folder to create the note in (supports nested paths like 'Work/Clients')"),
130
- account: z.string().optional().describe("Account name (defaults to iCloud)"),
160
+ account: z.string().max(MAX.ACCOUNT).optional().describe("Account name (defaults to iCloud)"),
131
161
  },
132
162
  outputSchema: {
133
163
  ok: z.boolean().optional(),
@@ -154,12 +184,13 @@ server.registerTool("create-note", {
154
184
  server.registerTool("search-notes", {
155
185
  description: "Use when: finding notes by a keyword in the title (or body with searchContent=true) and you need their ids.\nReturns: matching notes with title, folder, and id.\nDo not use when: you already have a note id (use get-note-content) or want every note (use list-notes).\nPrefer this first to obtain ids for subsequent read/update/delete/move calls.",
156
186
  inputSchema: {
157
- query: z.string().min(1, "Search query is required"),
187
+ query: z.string().min(1, "Search query is required").max(MAX.QUERY),
158
188
  searchContent: z.boolean().optional().describe("Search note content instead of titles"),
159
- account: z.string().optional().describe("Account to search in"),
160
- folder: z.string().optional().describe("Limit search to a specific folder"),
189
+ account: z.string().max(MAX.ACCOUNT).optional().describe("Account to search in"),
190
+ folder: z.string().max(MAX.FOLDER).optional().describe("Limit search to a specific folder"),
161
191
  modifiedSince: z
162
192
  .string()
193
+ .max(64)
163
194
  .optional()
164
195
  .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."),
165
196
  limit: z.number().int().positive().optional().describe("Maximum number of results to return"),
@@ -206,10 +237,19 @@ server.registerTool("search-notes", {
206
237
  server.registerTool("get-note-content", {
207
238
  description: "Use when: reading the full body text of one known note, by id (preferred) or title.\nReturns: the note's content plus parsed hashtags.\nDo not use when: you only need metadata (get-note-details) or Markdown with checklist state (get-note-markdown).\nNote: password-protected notes must be unlocked in Notes.app first.",
208
239
  inputSchema: {
209
- id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
210
- title: z.string().optional().describe("Note title (use id instead when available)"),
240
+ id: z
241
+ .string()
242
+ .max(MAX.ID)
243
+ .optional()
244
+ .describe("Note ID (preferred - more reliable than title)"),
245
+ title: z
246
+ .string()
247
+ .max(MAX.TITLE)
248
+ .optional()
249
+ .describe("Note title (use id instead when available)"),
211
250
  account: z
212
251
  .string()
252
+ .max(MAX.ACCOUNT)
213
253
  .optional()
214
254
  .describe("Account name (defaults to iCloud, ignored if id is provided)"),
215
255
  },
@@ -259,10 +299,19 @@ server.registerTool("get-note-content", {
259
299
  server.registerTool("get-note-plaintext", {
260
300
  description: "Use when: reading one note's body as plain text with no HTML, by id (preferred) or title.\nReturns: the note's plaintext exactly as Notes exposes it.\nDo not use when: you need the HTML body (get-note-content) or Markdown with checklist state (get-note-markdown).\nNote: this reads the note's native plaintext property, so it skips the HTML-to-text conversion; password-protected notes must be unlocked in Notes.app first.",
261
301
  inputSchema: {
262
- id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
263
- title: z.string().optional().describe("Note title (use id instead when available)"),
302
+ id: z
303
+ .string()
304
+ .max(MAX.ID)
305
+ .optional()
306
+ .describe("Note ID (preferred - more reliable than title)"),
307
+ title: z
308
+ .string()
309
+ .max(MAX.TITLE)
310
+ .optional()
311
+ .describe("Note title (use id instead when available)"),
264
312
  account: z
265
313
  .string()
314
+ .max(MAX.ACCOUNT)
266
315
  .optional()
267
316
  .describe("Account name (defaults to iCloud, ignored if id is provided)"),
268
317
  },
@@ -307,7 +356,7 @@ server.registerTool("get-note-plaintext", {
307
356
  server.registerTool("get-note-by-id", {
308
357
  description: "Use when: you have a note id and need its metadata only.\nReturns: id, title, created, modified, shared, passwordProtected.\nDo not use when: you need the body text (get-note-content) or only have a title (get-note-details).",
309
358
  inputSchema: {
310
- id: z.string().min(1, "Note ID is required"),
359
+ id: z.string().min(1, "Note ID is required").max(MAX.ID),
311
360
  },
312
361
  outputSchema: {
313
362
  id: z.string().optional(),
@@ -367,7 +416,7 @@ server.registerTool("get-note-details", {
367
416
  server.registerTool("show-note", {
368
417
  description: "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.",
369
418
  inputSchema: {
370
- id: z.string().min(1, "Note ID is required"),
419
+ id: z.string().min(1, "Note ID is required").max(MAX.ID),
371
420
  separately: z
372
421
  .boolean()
373
422
  .optional()
@@ -388,7 +437,7 @@ server.registerTool("show-note", {
388
437
  server.registerTool("show-folder", {
389
438
  description: "Use when: the user wants to reveal a known folder in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the folder list (list-folders).\nNote: this opens or focuses the Notes UI. Get the id from list-folders.",
390
439
  inputSchema: {
391
- id: z.string().min(1, "Folder ID is required"),
440
+ id: z.string().min(1, "Folder ID is required").max(MAX.ID),
392
441
  separately: z
393
442
  .boolean()
394
443
  .optional()
@@ -409,7 +458,7 @@ server.registerTool("show-folder", {
409
458
  server.registerTool("show-account", {
410
459
  description: "Use when: the user wants to reveal a known account in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the account list (list-accounts).\nNote: this opens or focuses the Notes UI. Get the id from list-accounts.",
411
460
  inputSchema: {
412
- id: z.string().min(1, "Account ID is required"),
461
+ id: z.string().min(1, "Account ID is required").max(MAX.ID),
413
462
  separately: z
414
463
  .boolean()
415
464
  .optional()
@@ -430,12 +479,21 @@ server.registerTool("show-account", {
430
479
  server.registerTool("update-note", {
431
480
  description: "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, and run list-attachments first when the note may hold files, images, scans, PDFs, or audio, since a full-body replace can drop embedded attachments. Edits to shared notes are immediately visible to all collaborators.",
432
481
  inputSchema: {
433
- id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
434
- title: z.string().optional().describe("Current note title (use id instead when available)"),
435
- newTitle: z.string().optional().describe("New title for the note"),
482
+ id: z
483
+ .string()
484
+ .max(MAX.ID)
485
+ .optional()
486
+ .describe("Note ID (preferred - more reliable than title)"),
487
+ title: z
488
+ .string()
489
+ .max(MAX.TITLE)
490
+ .optional()
491
+ .describe("Current note title (use id instead when available)"),
492
+ newTitle: z.string().max(MAX.TITLE).optional().describe("New title for the note"),
436
493
  newContent: z
437
494
  .string()
438
495
  .min(1, "New content is required")
496
+ .max(MAX.CONTENT)
439
497
  .describe("New note body. AppleScript cannot produce true Apple Notes checklists; checkbox inputs and `- [ ]` markdown do not render as checkable items. Use a plain list and convert in Notes.app with ⇧⌘L."),
440
498
  format: z
441
499
  .enum(["plaintext", "html"])
@@ -444,6 +502,7 @@ server.registerTool("update-note", {
444
502
  .describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
445
503
  account: z
446
504
  .string()
505
+ .max(MAX.ACCOUNT)
447
506
  .optional()
448
507
  .describe("Account containing the note (ignored if id is provided)"),
449
508
  },
@@ -513,10 +572,19 @@ server.registerTool("update-note", {
513
572
  server.registerTool("delete-note", {
514
573
  description: "Use when: permanently deleting a single note, by id (preferred) or title.\nReturns: confirmation; warns when the note was shared.\nDo not use when: deleting many notes (batch-delete-notes) or just relocating one (move-note).\nSafety: requires explicit user confirmation before deleting. Prefer search-notes/list-notes first to show the affected note id and title. Deleting a shared note removes collaborator access.",
515
574
  inputSchema: {
516
- id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
517
- title: z.string().optional().describe("Note title (use id instead when available)"),
575
+ id: z
576
+ .string()
577
+ .max(MAX.ID)
578
+ .optional()
579
+ .describe("Note ID (preferred - more reliable than title)"),
580
+ title: z
581
+ .string()
582
+ .max(MAX.TITLE)
583
+ .optional()
584
+ .describe("Note title (use id instead when available)"),
518
585
  account: z
519
586
  .string()
587
+ .max(MAX.ACCOUNT)
520
588
  .optional()
521
589
  .describe("Account name (defaults to iCloud, ignored if id is provided)"),
522
590
  },
@@ -574,12 +642,24 @@ server.registerTool("delete-note", {
574
642
  }, "Error deleting note"));
575
643
  // --- move-note ---
576
644
  server.registerTool("move-note", {
577
- description: "Use when: moving one note to a different folder, by id (preferred) or title.\nReturns: confirmation of the note and destination folder.\nDo not use when: moving many notes (batch-move-notes).\nNote: implemented as copy-then-delete; the destination folder must already exist (create-folder).",
645
+ description: "Use when: moving one note to a different folder, by id (preferred) or title.\nReturns: confirmation of the note and destination folder.\nDo not use when: moving many notes (batch-move-notes).\nNote: the note is relocated in place via Notes.app's native move, preserving its id, creation date, and all attachments. The destination folder must already exist (create-folder).",
578
646
  inputSchema: {
579
- id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
580
- title: z.string().optional().describe("Note title (use id instead when available)"),
581
- folder: z.string().min(1, "Destination folder is required"),
582
- account: z.string().optional().describe("Account containing the note/folder"),
647
+ id: z
648
+ .string()
649
+ .max(MAX.ID)
650
+ .optional()
651
+ .describe("Note ID (preferred - more reliable than title)"),
652
+ title: z
653
+ .string()
654
+ .max(MAX.TITLE)
655
+ .optional()
656
+ .describe("Note title (use id instead when available)"),
657
+ folder: z.string().min(1, "Destination folder is required").max(MAX.FOLDER),
658
+ account: z
659
+ .string()
660
+ .max(MAX.ACCOUNT)
661
+ .optional()
662
+ .describe("Account containing the note/folder"),
583
663
  },
584
664
  outputSchema: {
585
665
  ok: z.boolean().optional(),
@@ -629,10 +709,11 @@ server.registerTool("move-note", {
629
709
  server.registerTool("list-notes", {
630
710
  description: "Use when: enumerating notes in an account or folder; supports modifiedSince and limit for large collections.\nReturns: note titles only (no content or ids).\nDo not use when: you need content (get-note-content) or ids for follow-up edits (use search-notes).\nNote: warns if iCloud sync is active and results may be partial.",
631
711
  inputSchema: {
632
- account: z.string().optional().describe("Account to list notes from"),
633
- folder: z.string().optional().describe("Filter to specific folder"),
712
+ account: z.string().max(MAX.ACCOUNT).optional().describe("Account to list notes from"),
713
+ folder: z.string().max(MAX.FOLDER).optional().describe("Filter to specific folder"),
634
714
  modifiedSince: z
635
715
  .string()
716
+ .max(64)
636
717
  .optional()
637
718
  .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."),
638
719
  limit: z.number().int().positive().optional().describe("Maximum number of notes to return"),
@@ -693,7 +774,7 @@ server.registerTool("get-selected-notes", {
693
774
  server.registerTool("list-folders", {
694
775
  description: "Use when: listing all folders, with full nested paths, for an account.\nReturns: folder names/paths.\nDo not use when: listing notes (list-notes).\nNote: warns if iCloud sync is active.",
695
776
  inputSchema: {
696
- account: z.string().optional().describe("Account to list folders from"),
777
+ account: z.string().max(MAX.ACCOUNT).optional().describe("Account to list folders from"),
697
778
  },
698
779
  outputSchema: {
699
780
  folders: z.array(z.object({}).passthrough()).optional(),
@@ -728,8 +809,9 @@ server.registerTool("create-folder", {
728
809
  name: z
729
810
  .string()
730
811
  .min(1, "Folder name is required")
812
+ .max(MAX.FOLDER)
731
813
  .describe('Folder name or nested path separated by "/". E.g., "Retro Tech/PC/CPUs" creates all intermediate folders. Existing segments are skipped.'),
732
- account: z.string().optional().describe("Account name (defaults to iCloud)"),
814
+ account: z.string().max(MAX.ACCOUNT).optional().describe("Account name (defaults to iCloud)"),
733
815
  },
734
816
  outputSchema: {
735
817
  ok: z.boolean().optional(),
@@ -960,10 +1042,19 @@ server.registerTool("get-notes-stats", {
960
1042
  server.registerTool("list-attachments", {
961
1043
  description: "Use when: listing the attachments of one note, by id (preferred) or title.\nReturns: each attachment's name, content type, and id (use with save-attachment/fetch-attachment).\nDo not use when: you want the attachment bytes (fetch-attachment) or a file on disk (save-attachment).",
962
1044
  inputSchema: {
963
- id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
964
- title: z.string().optional().describe("Note title (use id instead when available)"),
1045
+ id: z
1046
+ .string()
1047
+ .max(MAX.ID)
1048
+ .optional()
1049
+ .describe("Note ID (preferred - more reliable than title)"),
1050
+ title: z
1051
+ .string()
1052
+ .max(MAX.TITLE)
1053
+ .optional()
1054
+ .describe("Note title (use id instead when available)"),
965
1055
  account: z
966
1056
  .string()
1057
+ .max(MAX.ACCOUNT)
967
1058
  .optional()
968
1059
  .describe("Account containing the note (ignored if id is provided)"),
969
1060
  },
@@ -1007,7 +1098,10 @@ server.registerTool("list-attachments", {
1007
1098
  server.registerTool("batch-delete-notes", {
1008
1099
  description: "Use when: permanently deleting multiple notes by id in one call.\nReturns: per-id success/failure counts.\nDo not use when: deleting a single note (delete-note).\nSafety: requires explicit user confirmation; this is destructive and not undoable. Prefer search-notes/list-notes first to confirm the exact ids being deleted.",
1009
1100
  inputSchema: {
1010
- ids: z.array(z.string()).describe("Array of note IDs to delete"),
1101
+ ids: z
1102
+ .array(z.string().max(MAX.ID))
1103
+ .max(MAX.BATCH_IDS)
1104
+ .describe("Array of note IDs to delete"),
1011
1105
  },
1012
1106
  outputSchema: {
1013
1107
  ok: z.boolean().optional(),
@@ -1042,10 +1136,14 @@ server.registerTool("batch-delete-notes", {
1042
1136
  server.registerTool("batch-move-notes", {
1043
1137
  description: "Use when: moving multiple notes by id into one destination folder.\nReturns: per-id success/failure counts.\nDo not use when: moving a single note (move-note).\nNote: the destination folder must already exist (create-folder).",
1044
1138
  inputSchema: {
1045
- ids: z.array(z.string()).describe("Array of note IDs to move"),
1046
- folder: z.string().describe("Destination folder name"),
1139
+ ids: z
1140
+ .array(z.string().max(MAX.ID))
1141
+ .max(MAX.BATCH_IDS)
1142
+ .describe("Array of note IDs to move"),
1143
+ folder: z.string().max(MAX.FOLDER).describe("Destination folder name"),
1047
1144
  account: z
1048
1145
  .string()
1146
+ .max(MAX.ACCOUNT)
1049
1147
  .optional()
1050
1148
  .describe("Account containing the destination folder (defaults to iCloud)"),
1051
1149
  },
@@ -1087,14 +1185,17 @@ server.registerTool("save-attachment", {
1087
1185
  noteId: z
1088
1186
  .string()
1089
1187
  .min(1, "noteId is required")
1188
+ .max(MAX.ID)
1090
1189
  .describe("CoreData note id (from search/list)"),
1091
1190
  attachmentId: z
1092
1191
  .string()
1093
1192
  .min(1, "attachmentId is required")
1193
+ .max(MAX.ATTACHMENT_ID)
1094
1194
  .describe("Attachment id (from list-attachments)"),
1095
1195
  savePath: z
1096
1196
  .string()
1097
1197
  .min(1, "savePath is required")
1198
+ .max(MAX.SAVE_PATH)
1098
1199
  .describe("Absolute destination file path (must be under home, temp, or /Volumes)"),
1099
1200
  },
1100
1201
  outputSchema: {
@@ -1120,10 +1221,12 @@ server.registerTool("fetch-attachment", {
1120
1221
  noteId: z
1121
1222
  .string()
1122
1223
  .min(1, "noteId is required")
1224
+ .max(MAX.ID)
1123
1225
  .describe("CoreData note id (from search/list)"),
1124
1226
  attachmentId: z
1125
1227
  .string()
1126
1228
  .min(1, "attachmentId is required")
1229
+ .max(MAX.ATTACHMENT_ID)
1127
1230
  .describe("Attachment id (from list-attachments)"),
1128
1231
  },
1129
1232
  outputSchema: {
@@ -1146,10 +1249,12 @@ server.registerTool("show-attachment", {
1146
1249
  noteId: z
1147
1250
  .string()
1148
1251
  .min(1, "noteId is required")
1252
+ .max(MAX.ID)
1149
1253
  .describe("CoreData note id (from search/list)"),
1150
1254
  attachmentId: z
1151
1255
  .string()
1152
1256
  .min(1, "attachmentId is required")
1257
+ .max(MAX.ATTACHMENT_ID)
1153
1258
  .describe("Attachment id (from list-attachments)"),
1154
1259
  separately: z
1155
1260
  .boolean()
@@ -1203,10 +1308,19 @@ server.registerTool("export-notes-json", {
1203
1308
  server.registerTool("get-note-markdown", {
1204
1309
  description: "Use when: reading a note as Markdown, with checklist items annotated [x]/[ ] when Full Disk Access is granted.\nReturns: the note's Markdown.\nDo not use when: you need the raw HTML/plaintext body (get-note-content) or only metadata (get-note-details).\nNote: falls back to plain lists (no checkmarks) without Full Disk Access.",
1205
1310
  inputSchema: {
1206
- id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
1207
- title: z.string().optional().describe("Note title (use id instead when available)"),
1311
+ id: z
1312
+ .string()
1313
+ .max(MAX.ID)
1314
+ .optional()
1315
+ .describe("Note ID (preferred - more reliable than title)"),
1316
+ title: z
1317
+ .string()
1318
+ .max(MAX.TITLE)
1319
+ .optional()
1320
+ .describe("Note title (use id instead when available)"),
1208
1321
  account: z
1209
1322
  .string()
1323
+ .max(MAX.ACCOUNT)
1210
1324
  .optional()
1211
1325
  .describe("Account containing the note (ignored if id is provided)"),
1212
1326
  },
@@ -1236,7 +1350,10 @@ server.registerTool("get-note-markdown", {
1236
1350
  server.registerTool("get-checklist-state", {
1237
1351
  description: "Use when: reading the checked/unchecked state of a note's checklist items, by id.\nReturns: each item's text and done state plus checked/total counts.\nDo not use when: you only have a title (get the id via search-notes first) or want the full body text (get-note-content).\nNote: requires Full Disk Access; reads the NoteStore database directly.",
1238
1352
  inputSchema: {
1239
- id: z.string().min(1, "Note ID is required. Use search-notes to find the note ID first."),
1353
+ id: z
1354
+ .string()
1355
+ .min(1, "Note ID is required. Use search-notes to find the note ID first.")
1356
+ .max(MAX.ID),
1240
1357
  },
1241
1358
  outputSchema: {
1242
1359
  items: z.array(z.object({}).passthrough()).optional(),
@@ -1266,7 +1383,10 @@ server.registerTool("get-checklist-state", {
1266
1383
  server.registerTool("get-note-metadata", {
1267
1384
  description: "[BETA] Use when: reading note metadata AppleScript cannot expose — pinned state, checklist flags, trash/recovery state, preview snippet, password hint — by id.\nReturns: a metadata object; fields vary by macOS version and are omitted when unavailable.\nDo not use when: you need the body (get-note-content) or per-item checklist state (get-checklist-state).\nNote: reads the NoteStore SQLite database read-only and requires Full Disk Access. BETA — the database schema changes between macOS releases, so some fields may be absent. Works on trashed notes that AppleScript can no longer resolve.",
1268
1385
  inputSchema: {
1269
- id: z.string().min(1, "Note ID is required. Use search-notes to find the note ID first."),
1386
+ id: z
1387
+ .string()
1388
+ .min(1, "Note ID is required. Use search-notes to find the note ID first.")
1389
+ .max(MAX.ID),
1270
1390
  },
1271
1391
  outputSchema: {
1272
1392
  pinned: z.boolean().optional(),
@@ -1314,5 +1434,23 @@ process.on("uncaughtException", (err) => {
1314
1434
  process.on("unhandledRejection", (reason) => {
1315
1435
  console.error("[unhandledRejection]", reason);
1316
1436
  });
1437
+ // Graceful shutdown. This server holds no persistent resources (AppleScript runs
1438
+ // are one-shot via execSync), so there's nothing to drain — but wiring SIGINT/
1439
+ // SIGTERM and stdin EOF/close to a clean exit keeps behavior tidy and consistent
1440
+ // with the sibling apple-mail server: when the parent kills us (signal) or the
1441
+ // MCP client disconnects (stdin 'end'/'close'), exit 0 promptly instead of
1442
+ // lingering as an orphan. Idempotent so multiple triggers don't double-exit.
1443
+ let _shuttingDown = false;
1444
+ const shutdown = () => {
1445
+ if (_shuttingDown)
1446
+ return;
1447
+ _shuttingDown = true;
1448
+ process.exit(0);
1449
+ };
1450
+ for (const sig of ["SIGINT", "SIGTERM"]) {
1451
+ process.on(sig, shutdown);
1452
+ }
1453
+ process.stdin.on("end", shutdown);
1454
+ process.stdin.on("close", shutdown);
1317
1455
  const transport = new StdioServerTransport();
1318
1456
  await server.connect(transport);
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { executeAppleScript } from "../utils/applescript.js";
18
18
  import { getChecklistItems } from "../utils/checklistParser.js";
19
- import { assertSafeSavePath, readFileBase64, fileSize, makeTempDir, cleanupTempDir, } from "../utils/attachmentFs.js";
19
+ import { assertSafeSavePath, readFileBase64Capped, fileSize, makeTempDir, cleanupTempDir, } from "../utils/attachmentFs.js";
20
20
  import { existsSync } from "fs";
21
21
  import TurndownService from "turndown";
22
22
  // =============================================================================
@@ -1395,99 +1395,65 @@ export class AppleNotesManager {
1395
1395
  return true;
1396
1396
  }
1397
1397
  /**
1398
- * Moves a note to a different folder.
1398
+ * Moves a note to a different folder, looked up by title.
1399
1399
  *
1400
- * Since AppleScript doesn't support direct note moves, this operation:
1401
- * 1. Retrieves the source note's content
1402
- * 2. Creates a new note with that content in the destination folder
1403
- * 3. Deletes the original note (only if copy succeeded)
1400
+ * Uses Notes.app's native `move` command (the same one `batchMoveNotes`
1401
+ * uses), which relocates the note in place — preserving its identity, id,
1402
+ * creation date, AND all embedded attachments (files/images/PDFs/scans/audio).
1403
+ * The previous copy-then-delete implementation rebuilt the note from its body
1404
+ * HTML, which silently dropped attachments and reset the note's identity.
1404
1405
  *
1405
- * This ensures the note is never lost - if the copy fails, the
1406
- * original remains untouched. If only the delete fails, the note
1407
- * exists in the new location (success is still returned).
1406
+ * The note is resolved to its id first (titles can be duplicated), then moved
1407
+ * by id so the title-based and id-based paths share the same native move.
1408
1408
  *
1409
1409
  * @param title - Title of the note to move
1410
- * @param destinationFolder - Name of the folder to move to
1410
+ * @param destinationFolder - Name of the folder to move to (must already exist)
1411
1411
  * @param account - Account containing the note (defaults to iCloud)
1412
- * @returns true if move succeeded (or copy succeeded but delete failed)
1412
+ * @returns true if the move succeeded, false otherwise
1413
1413
  */
1414
1414
  moveNote(title, destinationFolder, account) {
1415
1415
  const targetAccount = this.resolveAccount(account);
1416
- // Step 1: Get the original note's ID first (before creating a copy with the same title)
1416
+ // Resolve the note's id first (titles can be duplicated), then delegate to
1417
+ // the id-based native move so both paths preserve attachments + identity.
1417
1418
  const originalNote = this.getNoteDetails(title, targetAccount);
1418
1419
  if (!originalNote) {
1419
1420
  console.error(`Cannot move note "${title}": note not found`);
1420
1421
  return false;
1421
1422
  }
1422
- // Step 2: Retrieve the original note's content
1423
- const originalContent = this.getNoteContent(title, targetAccount);
1424
- if (!originalContent) {
1425
- console.error(`Cannot move note "${title}": failed to retrieve content`);
1426
- return false;
1427
- }
1428
- // Step 3: Create a copy in the destination folder
1429
- // Content is already HTML from getNoteContent(), so use escapeHtmlForAppleScript()
1430
- const folderRef = buildFolderReference(destinationFolder);
1431
- const safeContent = escapeHtmlForAppleScript(originalContent);
1432
- const createCommand = `make new note at ${folderRef} with properties {body:"${safeContent}"}`;
1433
- const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
1434
- const copyResult = executeAppleScript(script);
1435
- if (!copyResult.success) {
1436
- console.error(`Cannot move note "${title}": failed to create in destination folder:`, copyResult.error);
1437
- return false;
1438
- }
1439
- // Step 4: Delete the original by ID (not by title, since there are now two notes with the same title)
1440
- const safeOrigId = sanitizeId(originalNote.id);
1441
- const deleteCommand = `delete note id "${safeOrigId}"`;
1442
- const deleteScript = buildAppLevelScript(deleteCommand);
1443
- const deleteResult = executeAppleScript(deleteScript);
1444
- if (!deleteResult.success) {
1445
- // The note was copied successfully but we couldn't delete the original.
1446
- // This is still a partial success - the note exists in the new location.
1447
- console.error(`Note "${title}" was copied to "${destinationFolder}" but original could not be deleted:`, deleteResult.error);
1448
- return true;
1449
- }
1450
- return true;
1423
+ return this.moveNoteById(originalNote.id, destinationFolder, targetAccount);
1451
1424
  }
1452
1425
  /**
1453
1426
  * Moves a note to a different folder by its CoreData ID.
1454
1427
  *
1455
- * This is more reliable than moveNote() because IDs are unique,
1456
- * while titles can be duplicated.
1428
+ * Uses Notes.app's native `move <noteRef> to <destFolder>` command the same
1429
+ * one `batchMoveNotes` uses which relocates the note in place, preserving its
1430
+ * id, creation date, and all embedded attachments. (The old copy-then-delete
1431
+ * approach rebuilt the note from body HTML and silently lost attachments.)
1457
1432
  *
1458
1433
  * @param id - CoreData URL identifier for the note
1459
- * @param destinationFolder - Name of the folder to move to
1434
+ * @param destinationFolder - Name of the folder to move to (must already exist)
1460
1435
  * @param account - Account containing the destination folder (defaults to iCloud)
1461
- * @returns true if move succeeded (or copy succeeded but delete failed)
1436
+ * @returns true if the move succeeded, false otherwise
1462
1437
  */
1463
1438
  moveNoteById(id, destinationFolder, account) {
1464
1439
  const targetAccount = this.resolveAccount(account);
1465
1440
  const safeId = sanitizeId(id);
1466
- // Step 1: Retrieve the original note's content by ID
1467
- const originalContent = this.getNoteContentById(id);
1468
- if (!originalContent) {
1469
- console.error(`Cannot move note: note with ID "${id}" not found`);
1470
- return false;
1471
- }
1472
- // Step 2: Create a copy in the destination folder
1473
- // Content is already HTML from getNoteContentById(), so use escapeHtmlForAppleScript()
1474
- const folderRef = buildFolderReference(destinationFolder);
1475
- const safeContent = escapeHtmlForAppleScript(originalContent);
1476
- const createCommand = `make new note at ${folderRef} with properties {body:"${safeContent}"}`;
1477
- const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
1478
- const copyResult = executeAppleScript(script);
1479
- if (!copyResult.success) {
1480
- console.error(`Cannot move note: failed to create in destination folder:`, copyResult.error);
1441
+ const safeAccount = sanitizeAccountName(targetAccount);
1442
+ // buildFolderReference validates the destination path; a malformed folder is
1443
+ // a precondition error, so let it throw. The destination folder must already
1444
+ // exist Notes.app's `move` does not create it.
1445
+ const destFolderRef = `${buildFolderReference(destinationFolder)} of account "${safeAccount}"`;
1446
+ const moveCommand = `
1447
+ set destFolder to ${destFolderRef}
1448
+ set noteRef to note id "${safeId}"
1449
+ move noteRef to destFolder
1450
+ `;
1451
+ const script = buildAppLevelScript(moveCommand);
1452
+ const result = executeAppleScript(script);
1453
+ if (!result.success) {
1454
+ console.error(`Cannot move note to "${destinationFolder}" (folder may not exist):`, result.error);
1481
1455
  return false;
1482
1456
  }
1483
- // Step 3: Delete the original by ID
1484
- const deleteCommand = `delete note id "${safeId}"`;
1485
- const deleteScript = buildAppLevelScript(deleteCommand);
1486
- const deleteResult = executeAppleScript(deleteScript);
1487
- if (!deleteResult.success) {
1488
- console.error(`Note was copied to "${destinationFolder}" but original could not be deleted:`, deleteResult.error);
1489
- return true; // Partial success - note exists in new location
1490
- }
1491
1457
  return true;
1492
1458
  }
1493
1459
  // ===========================================================================
@@ -2184,11 +2150,15 @@ export class AppleNotesManager {
2184
2150
  if (!saved.success || !saved.savedPath) {
2185
2151
  return { success: false, error: saved.error };
2186
2152
  }
2153
+ // readFileBase64Capped checks the file size BEFORE reading and throws if it
2154
+ // exceeds APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES — the throw is caught below
2155
+ // and the temp dir is still cleaned up in `finally`.
2156
+ const base64 = readFileBase64Capped(saved.savedPath);
2187
2157
  return {
2188
2158
  success: true,
2189
2159
  name: saved.name,
2190
2160
  contentType: saved.contentType,
2191
- base64: readFileBase64(saved.savedPath),
2161
+ base64,
2192
2162
  bytes: fileSize(saved.savedPath),
2193
2163
  };
2194
2164
  }
@@ -1395,35 +1395,35 @@ describe("AppleNotesManager", () => {
1395
1395
  // Note Moving
1396
1396
  // ---------------------------------------------------------------------------
1397
1397
  describe("moveNote", () => {
1398
- it("returns true when move completes successfully", () => {
1399
- // Mock sequence: getNoteDetails -> getNoteContent -> createNote -> deleteNote
1398
+ // The note-details lookup output reused by the title-based move tests.
1399
+ const detailsOutput = [
1400
+ "My Note",
1401
+ "x-coredata://ABC/ICNote/p123",
1402
+ "Monday, January 1, 2024 at 12:00:00 PM",
1403
+ "Monday, January 1, 2024 at 12:00:00 PM",
1404
+ "false",
1405
+ "false",
1406
+ ].join(F);
1407
+ it("returns true when the native move completes successfully", () => {
1408
+ // Mock sequence: getNoteDetails (resolve id) -> native move
1400
1409
  mockExecuteAppleScript
1401
- .mockReturnValueOnce({
1402
- success: true,
1403
- output: [
1404
- "My Note",
1405
- "x-coredata://ABC/ICNote/p123",
1406
- "Monday, January 1, 2024 at 12:00:00 PM",
1407
- "Monday, January 1, 2024 at 12:00:00 PM",
1408
- "false",
1409
- "false",
1410
- ].join(F),
1411
- })
1412
- .mockReturnValueOnce({
1413
- success: true,
1414
- output: "<div>Note Title</div><div>Content</div>",
1415
- })
1416
- .mockReturnValueOnce({
1417
- success: true,
1418
- output: "note id x-coredata://...",
1419
- })
1420
- .mockReturnValueOnce({
1421
- success: true,
1422
- output: "",
1423
- });
1410
+ .mockReturnValueOnce({ success: true, output: detailsOutput })
1411
+ .mockReturnValueOnce({ success: true, output: "" });
1424
1412
  const result = manager.moveNote("My Note", "Archive");
1425
1413
  expect(result).toBe(true);
1426
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(4);
1414
+ // No copy-then-delete: just the details lookup + a single native `move`.
1415
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(2);
1416
+ });
1417
+ it("uses the native AppleScript `move` command (preserves attachments/identity)", () => {
1418
+ mockExecuteAppleScript
1419
+ .mockReturnValueOnce({ success: true, output: detailsOutput })
1420
+ .mockReturnValueOnce({ success: true, output: "" });
1421
+ manager.moveNote("My Note", "Archive");
1422
+ // The second call is the move; assert it issues a native `move ... to` and
1423
+ // does NOT rebuild the note via `make new note` (the old lossy path).
1424
+ const moveScript = mockExecuteAppleScript.mock.calls[1][0];
1425
+ expect(moveScript).toContain("move noteRef to destFolder");
1426
+ expect(moveScript).not.toContain("make new note");
1427
1427
  });
1428
1428
  it("returns false when source note cannot be found", () => {
1429
1429
  mockExecuteAppleScript.mockReturnValueOnce({
@@ -1435,23 +1435,9 @@ describe("AppleNotesManager", () => {
1435
1435
  expect(result).toBe(false);
1436
1436
  expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1); // Only tried to get details
1437
1437
  });
1438
- it("returns false when copy to destination fails", () => {
1438
+ it("returns false when the move fails (e.g. destination folder missing)", () => {
1439
1439
  mockExecuteAppleScript
1440
- .mockReturnValueOnce({
1441
- success: true,
1442
- output: [
1443
- "My Note",
1444
- "x-coredata://ABC/ICNote/p123",
1445
- "Monday, January 1, 2024 at 12:00:00 PM",
1446
- "Monday, January 1, 2024 at 12:00:00 PM",
1447
- "false",
1448
- "false",
1449
- ].join(F),
1450
- })
1451
- .mockReturnValueOnce({
1452
- success: true,
1453
- output: "<div>Content</div>",
1454
- })
1440
+ .mockReturnValueOnce({ success: true, output: detailsOutput })
1455
1441
  .mockReturnValueOnce({
1456
1442
  success: false,
1457
1443
  output: "",
@@ -1459,38 +1445,29 @@ describe("AppleNotesManager", () => {
1459
1445
  });
1460
1446
  const result = manager.moveNote("My Note", "Nonexistent Folder");
1461
1447
  expect(result).toBe(false);
1462
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(3); // Details + Read + failed create
1448
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(2); // Details + failed move
1463
1449
  });
1464
- it("returns true even if delete fails (note exists in new location)", () => {
1465
- // This is partial success - note was copied but original couldn't be deleted
1466
- mockExecuteAppleScript
1467
- .mockReturnValueOnce({
1468
- success: true,
1469
- output: [
1470
- "My Note",
1471
- "x-coredata://ABC/ICNote/p123",
1472
- "Monday, January 1, 2024 at 12:00:00 PM",
1473
- "Monday, January 1, 2024 at 12:00:00 PM",
1474
- "false",
1475
- "false",
1476
- ].join(F),
1477
- })
1478
- .mockReturnValueOnce({
1479
- success: true,
1480
- output: "<div>Content</div>",
1481
- })
1482
- .mockReturnValueOnce({
1483
- success: true,
1484
- output: "note id x-coredata://...",
1485
- })
1486
- .mockReturnValueOnce({
1450
+ });
1451
+ describe("moveNoteById", () => {
1452
+ it("returns true when the native move succeeds", () => {
1453
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
1454
+ const result = manager.moveNoteById("x-coredata://ABC/ICNote/p123", "Archive");
1455
+ expect(result).toBe(true);
1456
+ // Single native `move` — no getNoteContentById/create/delete fan-out.
1457
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
1458
+ const moveScript = mockExecuteAppleScript.mock.calls[0][0];
1459
+ expect(moveScript).toContain("move noteRef to destFolder");
1460
+ expect(moveScript).not.toContain("make new note");
1461
+ });
1462
+ it("returns false when the move fails", () => {
1463
+ mockExecuteAppleScript.mockReturnValueOnce({
1487
1464
  success: false,
1488
1465
  output: "",
1489
- error: "Cannot delete original",
1466
+ error: "Folder not found",
1490
1467
  });
1491
- const result = manager.moveNote("My Note", "Archive");
1492
- // Should still return true because the note exists in the destination
1493
- expect(result).toBe(true);
1468
+ const result = manager.moveNoteById("x-coredata://ABC/ICNote/p123", "Nonexistent");
1469
+ expect(result).toBe(false);
1470
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
1494
1471
  });
1495
1472
  });
1496
1473
  // ---------------------------------------------------------------------------
@@ -30,10 +30,48 @@ export function assertSafeSavePath(p, roots = allowedSaveRoots()) {
30
30
  }
31
31
  return abs;
32
32
  }
33
+ /**
34
+ * Default upper bound on an attachment that `fetch-attachment` will base64-encode
35
+ * into a single MCP response. `readFileSync` loads the whole file into memory and
36
+ * base64 grows it ~33%, so an unbounded read of a multi-GB attachment (video,
37
+ * disk image) could exhaust memory. 25 MB is generous for the inline-fetch use
38
+ * case (docs, images, PDFs); larger attachments should be exported to disk with
39
+ * `save-attachment` instead. Overridable via APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES.
40
+ */
41
+ const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
42
+ /** Resolve the configured max attachment size (bytes) for inline base64 fetch. */
43
+ export function maxAttachmentBytes(env = process.env) {
44
+ const raw = env.APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES;
45
+ if (raw !== undefined) {
46
+ const n = Number(raw);
47
+ if (Number.isFinite(n) && n > 0)
48
+ return n;
49
+ }
50
+ return DEFAULT_MAX_ATTACHMENT_BYTES;
51
+ }
33
52
  /** Read a file as base64. */
34
53
  export function readFileBase64(p) {
35
54
  return readFileSync(p).toString("base64");
36
55
  }
56
+ /**
57
+ * Read a file as base64, refusing files larger than `maxBytes`.
58
+ *
59
+ * Guards `fetch-attachment` against unbounded in-memory reads: the size is
60
+ * checked from filesystem metadata BEFORE the file is read, so an oversized
61
+ * attachment is rejected with a clear error instead of loading it (and its
62
+ * ~33%-larger base64) into memory. (`APPLE_NOTES_MCP_MAX_BUFFER` does not apply
63
+ * to `readFileSync`.)
64
+ *
65
+ * @throws if the file exceeds `maxBytes`
66
+ */
67
+ export function readFileBase64Capped(p, maxBytes = maxAttachmentBytes()) {
68
+ const size = fileSize(p);
69
+ if (size > maxBytes) {
70
+ throw new Error(`Attachment is ${size} bytes, exceeding the ${maxBytes}-byte fetch limit ` +
71
+ `(APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES). Use save-attachment to export it to disk instead.`);
72
+ }
73
+ return readFileBase64(p);
74
+ }
37
75
  /** Byte size of a file (0 if missing). */
38
76
  export function fileSize(p) {
39
77
  try {
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
2
2
  import { writeFileSync, existsSync, mkdtempSync } from "fs";
3
3
  import { homedir, tmpdir } from "os";
4
4
  import { join } from "path";
5
- import { assertSafeSavePath, readFileBase64, fileSize, makeTempDir, cleanupTempDir, allowedSaveRoots, } from "../utils/attachmentFs.js";
5
+ import { assertSafeSavePath, readFileBase64, readFileBase64Capped, maxAttachmentBytes, fileSize, makeTempDir, cleanupTempDir, allowedSaveRoots, } from "../utils/attachmentFs.js";
6
6
  const dirs = [];
7
7
  afterEach(() => dirs.splice(0).forEach(cleanupTempDir));
8
8
  describe("assertSafeSavePath (#27)", () => {
@@ -44,3 +44,26 @@ describe("base64 / size / temp helpers (#27)", () => {
44
44
  expect(() => cleanupTempDir(dir)).not.toThrow();
45
45
  });
46
46
  });
47
+ describe("readFileBase64Capped / maxAttachmentBytes (size guard)", () => {
48
+ it("reads files at or under the cap", () => {
49
+ const dir = mkdtempSync(join(tmpdir(), "anatt-"));
50
+ dirs.push(dir);
51
+ const f = join(dir, "ok.bin");
52
+ writeFileSync(f, Buffer.from("hello"));
53
+ expect(readFileBase64Capped(f, 1024)).toBe(Buffer.from("hello").toString("base64"));
54
+ });
55
+ it("throws (without reading) when the file exceeds the cap", () => {
56
+ const dir = mkdtempSync(join(tmpdir(), "anatt-"));
57
+ dirs.push(dir);
58
+ const f = join(dir, "big.bin");
59
+ writeFileSync(f, Buffer.alloc(2048));
60
+ expect(() => readFileBase64Capped(f, 1024)).toThrow(/exceeding the 1024-byte fetch limit/);
61
+ });
62
+ it("maxAttachmentBytes honors APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES and falls back to a sane default", () => {
63
+ expect(maxAttachmentBytes({ APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES: "12345" })).toBe(12345);
64
+ // Invalid / non-positive values fall back to the default (25 MB).
65
+ expect(maxAttachmentBytes({ APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES: "0" })).toBe(25 * 1024 * 1024);
66
+ expect(maxAttachmentBytes({ APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES: "nope" })).toBe(25 * 1024 * 1024);
67
+ expect(maxAttachmentBytes({})).toBe(25 * 1024 * 1024);
68
+ });
69
+ });
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * @module utils/syncDetection
13
13
  */
14
- import { execSync } from "child_process";
14
+ import { execFileSync } from "child_process";
15
15
  import * as fs from "fs";
16
16
  import * as path from "path";
17
17
  import * as os from "os";
@@ -76,7 +76,10 @@ export function getSyncStatus(useCache = true) {
76
76
  WHERE ZCURRENTLOCALVERSION > ZLATESTVERSIONSYNCEDTOCLOUD
77
77
  AND ZLATESTVERSIONSYNCEDTOCLOUD IS NOT NULL;
78
78
  `;
79
- const result = execSync(`sqlite3 -readonly "${NOTES_DB_PATH}" "${query.replace(/\n/g, " ")}"`, {
79
+ // Use execFileSync (argv array, no shell) to match the sibling sqlite callers
80
+ // (checklistParser.ts, noteMetadata.ts). The values here aren't user-controlled,
81
+ // but argv form avoids shell quoting/interpolation entirely.
82
+ const result = execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, query.replace(/\n/g, " ")], {
80
83
  encoding: "utf8",
81
84
  timeout: 5000,
82
85
  stdio: ["pipe", "pipe", "pipe"],
@@ -5,16 +5,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5
5
  import { getSyncStatus, logSyncWarning, withSyncAwareness, withSyncAwarenessSync, isSyncActive, getSyncStatusSummary, clearSyncStatusCache, } from "./syncDetection.js";
6
6
  // Mock child_process
7
7
  vi.mock("child_process", () => ({
8
- execSync: vi.fn(),
8
+ execFileSync: vi.fn(),
9
9
  }));
10
10
  // Mock fs
11
11
  vi.mock("fs", () => ({
12
12
  existsSync: vi.fn(),
13
13
  statSync: vi.fn(),
14
14
  }));
15
- import { execSync } from "child_process";
15
+ import { execFileSync } from "child_process";
16
16
  import * as fs from "fs";
17
- const mockExecSync = vi.mocked(execSync);
17
+ const mockExecSync = vi.mocked(execFileSync);
18
18
  const mockExistsSync = vi.mocked(fs.existsSync);
19
19
  const mockStatSync = vi.mocked(fs.statSync);
20
20
  describe("getSyncStatus", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.5.4",
3
+ "version": "2.5.6",
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",
@@ -13,24 +13,6 @@
13
13
  "README.md",
14
14
  "LICENSE"
15
15
  ],
16
- "scripts": {
17
- "build": "tsc && tsc-alias",
18
- "start": "node build/index.js",
19
- "dev": "tsc --watch",
20
- "test": "vitest run",
21
- "test:watch": "vitest",
22
- "test:coverage": "vitest run --coverage",
23
- "test:integration": "vitest run --config vitest.integration.config.ts",
24
- "test:all": "vitest run && vitest run --config vitest.integration.config.ts",
25
- "lint": "eslint src",
26
- "lint:fix": "eslint src --fix",
27
- "format": "prettier --write src",
28
- "format:check": "prettier --check src",
29
- "typecheck": "tsc --noEmit",
30
- "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin .agents/plugins codex .hermes-plugin .antigravity-plugin",
31
- "prepublishOnly": "npm run lint && npm run test && npm run build",
32
- "prepare": "husky; npm run build"
33
- },
34
16
  "keywords": [
35
17
  "mcp",
36
18
  "apple-notes",
@@ -64,6 +46,7 @@
64
46
  "zod": "^3.22.4"
65
47
  },
66
48
  "devDependencies": {
49
+ "@eslint/js": "^9.0.0",
67
50
  "@types/node": "^20.0.0",
68
51
  "@types/turndown": "^5.0.6",
69
52
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -88,5 +71,21 @@
88
71
  "eslint --fix",
89
72
  "prettier --write"
90
73
  ]
74
+ },
75
+ "scripts": {
76
+ "build": "tsc && tsc-alias",
77
+ "start": "node build/index.js",
78
+ "dev": "tsc --watch",
79
+ "test": "vitest run",
80
+ "test:watch": "vitest",
81
+ "test:coverage": "vitest run --coverage",
82
+ "test:integration": "vitest run --config vitest.integration.config.ts",
83
+ "test:all": "vitest run && vitest run --config vitest.integration.config.ts",
84
+ "lint": "eslint src",
85
+ "lint:fix": "eslint src --fix",
86
+ "format": "prettier --write src",
87
+ "format:check": "prettier --check src",
88
+ "typecheck": "tsc --noEmit",
89
+ "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin .agents/plugins codex .hermes-plugin .antigravity-plugin"
91
90
  }
92
- }
91
+ }