apple-notes-mcp 2.5.5 → 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 +2 -1
- package/build/index.js +179 -41
- package/build/services/appleNotesManager.js +39 -69
- package/build/services/appleNotesManager.test.js +47 -70
- package/build/utils/attachmentFs.js +38 -0
- package/build/utils/attachmentFs.test.js +24 -1
- package/build/utils/syncDetection.js +5 -2
- package/build/utils/syncDetection.test.js +3 -3
- package/package.json +1 -1
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
|
|
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
|
|
210
|
-
|
|
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
|
|
263
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
517
|
-
|
|
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:
|
|
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
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
964
|
-
|
|
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
|
|
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
|
|
1046
|
-
|
|
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
|
|
1207
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
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
|
-
*
|
|
1406
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
1456
|
-
*
|
|
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
|
|
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
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
const
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
|
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
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
1448
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledTimes(2); // Details + failed move
|
|
1463
1449
|
});
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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: "
|
|
1466
|
+
error: "Folder not found",
|
|
1490
1467
|
});
|
|
1491
|
-
const result = manager.
|
|
1492
|
-
|
|
1493
|
-
expect(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
15
|
+
import { execFileSync } from "child_process";
|
|
16
16
|
import * as fs from "fs";
|
|
17
|
-
const mockExecSync = vi.mocked(
|
|
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