@mgcrea/mcp-apple-notes 0.0.0-bootstrap

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.
@@ -0,0 +1,1550 @@
1
+ import { AppBusyError as NotesBusyError, AppNotRunningError as NotesNotRunningError, AppleAutomationError, AppleAutomationError as AppleNotesError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, PreconditionError, SchemaDriftError, columnsOf, confirmArg, createOsascriptRunner, describeStore, escapeLike, fingerprintSchema, limitArg, ok, okText, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, readPackageIdentity, trimmed, withBusyRetry, wrap, wrapResult } from "@mgcrea/mcp-apple-core";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve, sep } from "node:path";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
+ import { gunzipSync } from "node:zlib";
6
+ import { z } from "zod";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ //#region src/build-info.ts
9
+ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
10
+ name: "@mgcrea/mcp-apple-notes",
11
+ version: "0.0.0"
12
+ });
13
+ const BUILD_INFO = {
14
+ name: pkg.name,
15
+ version: pkg.version,
16
+ gitCommit: "df06e7f",
17
+ gitCommitDate: "2026-08-22T16:20:23+02:00"
18
+ };
19
+ //#endregion
20
+ //#region src/client/errors.ts
21
+ /**
22
+ * Notes' error surface. The taxonomy lives in `@mgcrea/mcp-apple-core`; what
23
+ * belongs here is the identity those messages are written against, and the
24
+ * errors that are genuinely about notes.
25
+ */
26
+ /** Named in every user-facing error and in the env vars they mention. */
27
+ const NOTES_SURFACE = {
28
+ appName: "Notes",
29
+ envPrefix: "APPLE_NOTES"
30
+ };
31
+ /** A NoteRef no longer resolves — the note was deleted, or the index is stale. */
32
+ var NoteNotFoundError = class extends AppleAutomationError {
33
+ name = "NoteNotFoundError";
34
+ constructor(ref) {
35
+ super(`No note for ref "${ref}". It was probably deleted since the search ran. Re-run the search to get a current ref.`);
36
+ }
37
+ };
38
+ /**
39
+ * The note is password-protected and Notes will not hand over its text.
40
+ *
41
+ * The file lane cannot help either: locked notes are AES ciphertext at rest,
42
+ * which is why `ZICNOTEDATA` carries `ZCRYPTOINITIALIZATIONVECTOR` and
43
+ * `ZCRYPTOTAG` columns. Saying so is more useful than an empty body.
44
+ */
45
+ var NoteLockedError = class extends AppleAutomationError {
46
+ name = "NoteLockedError";
47
+ constructor(ref) {
48
+ super(`Note "${ref}" is password-protected. Unlock it in Notes and retry — its text is encrypted at rest, so no amount of permission makes it readable from here.`);
49
+ }
50
+ };
51
+ //#endregion
52
+ //#region src/client/locate.ts
53
+ /**
54
+ * Find Notes' store.
55
+ *
56
+ * Simpler than Mail's, and measurably so: Mail's root is version-numbered
57
+ * (`~/Library/Mail/V10`) *and* TCC-hidden, so it needs a four-branch strategy
58
+ * ladder that asks Mail to name its own directory. Notes has one constant path,
59
+ * confirmed on macOS 26.6.
60
+ *
61
+ * The path is TCC-protected, so `statSync` succeeds on it while `open` and
62
+ * `access` are denied — existence and readability are different questions, and
63
+ * only readability answers whether Full Disk Access is granted.
64
+ */
65
+ /** `~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite`. */
66
+ const GROUP_CONTAINER = "group.com.apple.notes";
67
+ const defaultStorePath = (home = homedir()) => join(home, "Library", "Group Containers", GROUP_CONTAINER, "NoteStore.sqlite");
68
+ const locateStore = (opts = {}) => {
69
+ const storePath = opts.storePath ?? defaultStorePath(opts.home);
70
+ const facts = describeStore(storePath);
71
+ const reason = facts.exists ? facts.readable ? null : "NoteStore.sqlite exists but cannot be read. Grant Full Disk Access to the app running this server (System Settings > Privacy & Security > Full Disk Access) and restart it. Granting it to Notes.app does nothing — the reader needs the permission." : `No NoteStore.sqlite at ${storePath}. Has Notes ever been set up on this account?`;
72
+ return {
73
+ ...facts,
74
+ storePath,
75
+ mediaRoot: join(dirname(storePath), "Accounts"),
76
+ reason
77
+ };
78
+ };
79
+ //#endregion
80
+ //#region src/client/jxa/core.ts
81
+ /**
82
+ * JXA script fragments.
83
+ *
84
+ * Every script here is a static constant. None of them may contain a template
85
+ * interpolation — `assertStaticScript` rejects any script containing `${`, and
86
+ * that includes JavaScript template literals written *inside* the JXA source.
87
+ * Use string concatenation in JXA code.
88
+ *
89
+ * Every script follows the same contract:
90
+ * - it reads its parameters from `JSON.parse(argv[0])`
91
+ * - it returns `JSON.stringify({ok: true, data})` on success
92
+ * - it returns `JSON.stringify({ok: false, error: {code, message}})` on an
93
+ * application-level failure, still exiting 0
94
+ * so that a non-zero exit always means infrastructure rather than "no such note".
95
+ *
96
+ * ## Two rules the measurements impose
97
+ *
98
+ * 1. **Never read a property per note in a loop.** One note's `plaintext` costs
99
+ * ~116ms, so a 921-note library would take ~107 seconds. Every read here is a
100
+ * bulk array fetch (`N.notes.plaintext()`), which is one Apple Event whatever
101
+ * the size.
102
+ * 2. **Never use `whose` for text search.** `notes.whose({plaintext: ...})` took
103
+ * 671ms against 97ms for pulling everything and filtering in JS — 6.9x slower,
104
+ * because the specifier evaluates per note across the Apple Event bridge while
105
+ * the bulk form is a single round trip.
106
+ */
107
+ /**
108
+ * Shared prelude.
109
+ *
110
+ * The liveness gate matters more than it looks. `Application("Notes").notes`
111
+ * *launches* Notes if it isn't running — which steals focus as a side effect of
112
+ * a read. `NSRunningApplication` answers the same question without launching
113
+ * anything and without a second TCC prompt.
114
+ *
115
+ * Deliberately NOT shared with Mail's prelude: 45% of that file is a UI-scripting
116
+ * workaround for Mail's forced blockquote citation, which every script there
117
+ * carries whether it composes mail or lists accounts. Notes needs none of it.
118
+ */
119
+ const PRELUDE = `
120
+ ObjC.import("AppKit");
121
+
122
+ function isNotesRunning() {
123
+ var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.Notes");
124
+ return apps.count > 0;
125
+ }
126
+
127
+ function ok(data) { return JSON.stringify({ ok: true, data: data }); }
128
+ function err(code, message) { return JSON.stringify({ ok: false, error: { code: code, message: String(message) } }); }
129
+
130
+ function iso(d) {
131
+ try { return d ? d.toISOString() : null; } catch (e) { return null; }
132
+ }
133
+
134
+ /** Read one property defensively: Notes throws on properties it cannot supply. */
135
+ function prop(fn, fallback) {
136
+ try {
137
+ var v = fn();
138
+ return v === undefined ? fallback : v;
139
+ } catch (e) {
140
+ return fallback;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Zip parallel bulk arrays into records.
146
+ *
147
+ * Bulk fetches are the only affordable read shape, and they come back as
148
+ * separate arrays that line up by index. Rebuilding records here keeps that
149
+ * assumption in one place.
150
+ */
151
+ function zip(keys, columns, count) {
152
+ var out = [];
153
+ for (var i = 0; i < count; i++) {
154
+ var row = {};
155
+ for (var k = 0; k < keys.length; k++) {
156
+ var col = columns[k];
157
+ row[keys[k]] = col && i < col.length ? col[i] : null;
158
+ }
159
+ out.push(row);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /** Walk the folder tree. Folders nest in the dictionary, so this recurses. */
165
+ function folderTree(container, accountId, depth, out) {
166
+ if (depth > 8) return out;
167
+ var subs = [];
168
+ try { subs = container.folders(); } catch (e) { return out; }
169
+ for (var i = 0; i < subs.length; i++) {
170
+ var f = subs[i];
171
+ var id = prop(function () { return String(f.id()); }, null);
172
+ out.push({
173
+ id: id,
174
+ name: prop(function () { return String(f.name()); }, null),
175
+ accountId: accountId,
176
+ depth: depth,
177
+ shared: prop(function () { return f.shared(); }, false),
178
+ noteCount: prop(function () { return f.notes().length; }, null)
179
+ });
180
+ folderTree(f, accountId, depth + 1, out);
181
+ }
182
+ return out;
183
+ }
184
+ `;
185
+ /**
186
+ * Wrap a script body in the prelude and the liveness gate.
187
+ *
188
+ * `allowLaunch` is opt-in per script, and only tools whose whole purpose is to
189
+ * make Notes do something set it. A read that silently launches Notes is a read
190
+ * with a side effect.
191
+ */
192
+ const script = (body, opts = {}) => `
193
+ ${PRELUDE}
194
+ function run(argv) {
195
+ var p = JSON.parse(argv[0] || "{}");
196
+ if (!isNotesRunning() && !${opts.allowLaunch ? "true" : "false"}) {
197
+ return err("APP_NOT_RUNNING", "Notes is not running.");
198
+ }
199
+ try {
200
+ var N = Application("Notes");
201
+ ${body}
202
+ } catch (e) {
203
+ var msg = String(e && e.message ? e.message : e);
204
+ if (msg.indexOf("-1743") !== -1) return err("NOT_AUTHORIZED", msg);
205
+ return err("SCRIPT_ERROR", msg);
206
+ }
207
+ }
208
+ `;
209
+ //#endregion
210
+ //#region src/client/jxa/read.ts
211
+ /** Accounts, with the folder count each one holds. */
212
+ const LIST_ACCOUNTS = script(`
213
+ var accounts = N.accounts();
214
+ var out = [];
215
+ for (var i = 0; i < accounts.length; i++) {
216
+ var a = accounts[i];
217
+ out.push({
218
+ id: prop(function () { return String(a.id()); }, null),
219
+ name: prop(function () { return String(a.name()); }, null),
220
+ defaultFolder: prop(function () { return String(a.defaultFolder.name()); }, null),
221
+ folderCount: prop(function () { return a.folders().length; }, 0),
222
+ noteCount: prop(function () { return a.notes().length; }, 0)
223
+ });
224
+ }
225
+ return ok(out);
226
+ `);
227
+ /** The folder tree. Folders nest, so this is depth-first rather than a flat list. */
228
+ const LIST_FOLDERS = script(`
229
+ var accounts = N.accounts();
230
+ var out = [];
231
+ for (var i = 0; i < accounts.length; i++) {
232
+ var a = accounts[i];
233
+ var accountId = prop(function () { return String(a.id()); }, null);
234
+ var accountName = prop(function () { return String(a.name()); }, null);
235
+ var folders = folderTree(a, accountId, 1, []);
236
+ for (var j = 0; j < folders.length; j++) {
237
+ folders[j].accountName = accountName;
238
+ out.push(folders[j]);
239
+ }
240
+ }
241
+ return ok(out);
242
+ `);
243
+ /**
244
+ * Every note's metadata, as bulk arrays.
245
+ *
246
+ * One Apple Event per property for the whole library — ~0.1ms per note — versus
247
+ * ~116ms per note for reading properties one note at a time. Folder membership
248
+ * is resolved with one bulk call per *folder*, which is a handful of round trips
249
+ * rather than one per note.
250
+ */
251
+ const BULK_NOTES = script(`
252
+ var ids = N.notes.id();
253
+ var count = ids.length;
254
+ var rows = zip(
255
+ ["id", "name", "modified", "created", "locked", "shared"],
256
+ [
257
+ ids,
258
+ prop(function () { return N.notes.name(); }, []),
259
+ prop(function () { return N.notes.modificationDate(); }, []),
260
+ prop(function () { return N.notes.creationDate(); }, []),
261
+ prop(function () { return N.notes.passwordProtected(); }, []),
262
+ prop(function () { return N.notes.shared(); }, [])
263
+ ],
264
+ count
265
+ );
266
+
267
+ // Note -> folder, one bulk call per folder rather than one per note.
268
+ var folderOf = {};
269
+ var accounts = N.accounts();
270
+ for (var i = 0; i < accounts.length; i++) {
271
+ var a = accounts[i];
272
+ var accountId = prop(function () { return String(a.id()); }, null);
273
+ var accountName = prop(function () { return String(a.name()); }, null);
274
+ var folders = folderTree(a, accountId, 1, []);
275
+ for (var j = 0; j < folders.length; j++) {
276
+ var f = folders[j];
277
+ var noteIds = [];
278
+ try {
279
+ var handle = N.folders.byId(f.id);
280
+ noteIds = handle.notes.id();
281
+ } catch (e) { noteIds = []; }
282
+ for (var k = 0; k < noteIds.length; k++) {
283
+ folderOf[String(noteIds[k])] = { folderId: f.id, folder: f.name, accountId: accountId, account: accountName };
284
+ }
285
+ }
286
+ }
287
+
288
+ for (var r = 0; r < rows.length; r++) {
289
+ var row = rows[r];
290
+ row.id = String(row.id);
291
+ row.modified = iso(row.modified);
292
+ row.created = iso(row.created);
293
+ var where = folderOf[row.id];
294
+ row.folder = where ? where.folder : null;
295
+ row.folderId = where ? where.folderId : null;
296
+ row.account = where ? where.account : null;
297
+ row.accountId = where ? where.accountId : null;
298
+ }
299
+ return ok({ count: count, notes: rows });
300
+ `);
301
+ /**
302
+ * Every note's id and plaintext, in one round trip.
303
+ *
304
+ * This is the search lane when the index is unavailable: pull everything, filter
305
+ * in JS. Measured at 97ms over 921 notes and 250KB — against 671ms for the
306
+ * equivalent `whose` specifier.
307
+ */
308
+ const BULK_PLAINTEXT = script(`
309
+ return ok({ ids: N.notes.id(), texts: N.notes.plaintext() });
310
+ `);
311
+ /** Bodies for a specific set of notes, by id. Used after the index narrows things down. */
312
+ const GET_NOTE_BODIES = script(`
313
+ var out = [];
314
+ for (var i = 0; i < p.ids.length; i++) {
315
+ var id = String(p.ids[i]);
316
+ var note = null;
317
+ try { note = N.notes.byId(id); } catch (e) { note = null; }
318
+ if (!note) { out.push({ id: id, found: false }); continue; }
319
+ var locked = prop(function () { return note.passwordProtected(); }, false);
320
+ out.push({
321
+ id: id,
322
+ found: true,
323
+ locked: locked,
324
+ name: prop(function () { return String(note.name()); }, null),
325
+ plaintext: locked ? null : prop(function () { return String(note.plaintext()); }, null),
326
+ body: locked ? null : prop(function () { return String(note.body()); }, null),
327
+ modified: iso(prop(function () { return note.modificationDate(); }, null)),
328
+ created: iso(prop(function () { return note.creationDate(); }, null))
329
+ });
330
+ }
331
+ return ok(out);
332
+ `);
333
+ /** Attachment metadata for one note. There is no filesystem path in the dictionary. */
334
+ const LIST_ATTACHMENTS = script(`
335
+ var note = null;
336
+ try { note = N.notes.byId(String(p.id)); } catch (e) { note = null; }
337
+ if (!note) return err("NOTE_NOT_FOUND", "No note with id " + p.id);
338
+ var atts = prop(function () { return note.attachments(); }, []);
339
+ var out = [];
340
+ for (var i = 0; i < atts.length; i++) {
341
+ var a = atts[i];
342
+ out.push({
343
+ id: prop(function () { return String(a.id()); }, null),
344
+ name: prop(function () { return String(a.name()); }, null),
345
+ contentIdentifier: prop(function () { return String(a.contentIdentifier()); }, null),
346
+ url: prop(function () { return String(a.URL()); }, null),
347
+ created: iso(prop(function () { return a.creationDate(); }, null)),
348
+ modified: iso(prop(function () { return a.modificationDate(); }, null))
349
+ });
350
+ }
351
+ return ok(out);
352
+ `);
353
+ //#endregion
354
+ //#region src/client/jxa/repair.ts
355
+ /**
356
+ * Repair for Notes' lossy `body` round trip.
357
+ *
358
+ * `UPDATE_NOTE`'s append mode reads `note.body()` and assigns the result back,
359
+ * so whatever the getter mangles is written back mangled — onto content the
360
+ * caller never touched. Two independent losses, both reproduced against Notes
361
+ * on macOS 15:
362
+ *
363
+ * 1. **Entities come back unterminated.** The text `ps."projectId";` serialises
364
+ * to `ps.&quotprojectId&quot;` — `&quot` with no semicolon. Fed back to the
365
+ * setter, the trailing `&quot` fuses with the literal `;` that followed it
366
+ * into a single `"`, and the semicolon is gone. The getter never terminates
367
+ * an entity, so a `&quot;` in its output is always entity-plus-literal-`;`,
368
+ * never a well-formed `&quot;`. That is what makes the repair unconditional
369
+ * rather than a `(?!;)` lookahead — a lookahead would skip exactly the case
370
+ * that loses a character.
371
+ * 2. **Space runs come back literal**, and HTML collapses them on re-parse, so
372
+ * every indented line loses its indentation and every aligned block loses
373
+ * its alignment.
374
+ *
375
+ * Order matters: entities first, then spaces. The space pass emits `&nbsp;`
376
+ * with a terminating semicolon, and running the entity pass afterwards would
377
+ * rewrite those into `&nbsp;;`.
378
+ *
379
+ * Exported as source rather than a function because it runs inside the JXA
380
+ * script, in osascript's JavaScriptCore. `test/repair.test.ts` evaluates this
381
+ * same string, so the tested code is the shipped code.
382
+ */
383
+ const REPAIR_BODY_SRC = `
384
+ var repairBody = function (html) {
385
+ var entities = html
386
+ .replace(/&(amp|quot|apos|lt|gt|nbsp)/g, "&$1;")
387
+ .replace(/&#(x?[0-9a-fA-F]+)/g, "&#$1;");
388
+ return entities.replace(/>([^<]*)</g, function (_m, text) {
389
+ return ">" + text
390
+ .replace(/ {2,}/g, function (run) { return new Array(run.length + 1).join("&nbsp;"); })
391
+ .replace(/^ /, "&nbsp;") + "<";
392
+ });
393
+ };
394
+ `;
395
+ //#endregion
396
+ //#region src/client/jxa/write.ts
397
+ /**
398
+ * Mutations may launch Notes.
399
+ *
400
+ * A read that silently launches an app is a read with a side effect, which is
401
+ * why the read scripts refuse. A tool whose whole purpose is to create or change
402
+ * a note has no such problem — the user asked for the change.
403
+ */
404
+ const MUTATION = { allowLaunch: true };
405
+ /**
406
+ * Create a note.
407
+ *
408
+ * Notes derives a note's `name` from the first line of its body rather than from
409
+ * a settable property, so the title is prepended as a heading instead of
410
+ * assigned. Setting `name` directly is silently ignored.
411
+ */
412
+ const CREATE_NOTE = script(`
413
+ var folder = null;
414
+ if (p.folderId) {
415
+ try { folder = N.folders.byId(String(p.folderId)); } catch (e) { folder = null; }
416
+ if (!folder) return err("FOLDER_NOT_FOUND", "No folder with id " + p.folderId);
417
+ } else {
418
+ var accounts = N.accounts();
419
+ if (!accounts.length) return err("NO_ACCOUNT", "Notes reports no accounts.");
420
+ folder = accounts[0].defaultFolder;
421
+ }
422
+
423
+ var html = "";
424
+ if (p.title) html += "<h1>" + p.title + "</h1>";
425
+ if (p.body) html += p.body;
426
+ if (!html) return err("EMPTY_NOTE", "A note needs a title or a body.");
427
+
428
+ var note = N.Note({ body: html });
429
+ folder.notes.push(note);
430
+ return ok({
431
+ id: prop(function () { return String(note.id()); }, null),
432
+ name: prop(function () { return String(note.name()); }, null),
433
+ folder: prop(function () { return String(folder.name()); }, null)
434
+ });
435
+ `, MUTATION);
436
+ /**
437
+ * Replace a note's body. The title follows the first line, as it does on create.
438
+ *
439
+ * Append re-serialises the *existing* body through Notes' HTML parser, which is
440
+ * lossy in both directions — see `repair.ts`. `repairBody` undoes the getter's
441
+ * damage before the concatenation, so an append leaves untouched content byte
442
+ * identical instead of silently flattening indentation and eating characters.
443
+ */
444
+ const UPDATE_NOTE = script(REPAIR_BODY_SRC + `
445
+ var note = null;
446
+ try { note = N.notes.byId(String(p.id)); } catch (e) { note = null; }
447
+ if (!note) return err("NOTE_NOT_FOUND", "No note with id " + p.id);
448
+ if (prop(function () { return note.passwordProtected(); }, false)) {
449
+ return err("NOTE_LOCKED", "That note is password-protected; unlock it in Notes first.");
450
+ }
451
+
452
+ var current = prop(function () { return String(note.body()); }, "");
453
+ var next = p.mode === "append" ? repairBody(current) + (p.body || "") : (p.body || "");
454
+ note.body = next;
455
+ return ok({
456
+ id: String(p.id),
457
+ name: prop(function () { return String(note.name()); }, null),
458
+ bodyLength: next.length,
459
+ mode: p.mode || "replace"
460
+ });
461
+ `, MUTATION);
462
+ /** Move a note to another folder. */
463
+ const MOVE_NOTE = script(`
464
+ var note = null;
465
+ try { note = N.notes.byId(String(p.id)); } catch (e) { note = null; }
466
+ if (!note) return err("NOTE_NOT_FOUND", "No note with id " + p.id);
467
+ var folder = null;
468
+ try { folder = N.folders.byId(String(p.folderId)); } catch (e) { folder = null; }
469
+ if (!folder) return err("FOLDER_NOT_FOUND", "No folder with id " + p.folderId);
470
+
471
+ N.move(note, { to: folder });
472
+ return ok({
473
+ id: String(p.id),
474
+ folder: prop(function () { return String(folder.name()); }, null)
475
+ });
476
+ `, MUTATION);
477
+ /**
478
+ * Delete notes.
479
+ *
480
+ * Notes moves them to Recently Deleted rather than destroying them, which is
481
+ * what makes this safe enough to expose at all — but it is still gated behind an
482
+ * explicit confirm at the tool layer.
483
+ */
484
+ const DELETE_NOTES = script(`
485
+ var deleted = [];
486
+ var missing = [];
487
+ for (var i = 0; i < p.ids.length; i++) {
488
+ var id = String(p.ids[i]);
489
+ var note = null;
490
+ try { note = N.notes.byId(id); } catch (e) { note = null; }
491
+ if (!note) { missing.push(id); continue; }
492
+ try { N.delete(note); deleted.push(id); } catch (e) { missing.push(id); }
493
+ }
494
+ return ok({ deleted: deleted, missing: missing, note: "Deleted notes go to Recently Deleted." });
495
+ `, MUTATION);
496
+ //#endregion
497
+ //#region src/client/ref.ts
498
+ /**
499
+ * NoteRef — the one identifier any tool accepts or returns.
500
+ *
501
+ * Notes' own id is already globally unique and resolvable without a container,
502
+ * unlike Mail's message ids: `x-coredata://<store>/ICNote/p<N>` addresses a note
503
+ * outright, so this carries no folder and needs no lookup ladder.
504
+ *
505
+ * Wire format: n1:<coredata-id>
506
+ *
507
+ * The `n1:` prefix is deliberate. If the id scheme ever changes, a versioned
508
+ * prefix makes that an additive change instead of a silent reinterpretation of
509
+ * every ref already in a conversation.
510
+ *
511
+ * Deliberately NOT shared with Mail's `ref.ts`: that codec's `#(\d+)$` and
512
+ * `Number.isSafeInteger` both assume an integer id, which this is not.
513
+ */
514
+ const REF_VERSION = "n1";
515
+ /** `x-coredata://<uuid>/ICNote/p123` — the primary key is the trailing p-number. */
516
+ const CORE_DATA_ID = /^x-coredata:\/\/[^/]+\/ICNote\/p(\d+)$/;
517
+ const encodeRef = (id) => `n1:${id}`;
518
+ const decodeRef = (raw) => {
519
+ const m = /^([a-z0-9]+):(.+)$/.exec(raw);
520
+ if (!m) throw new PreconditionError(`Malformed note ref "${raw}". Refs come from the search and list tools — construct them from those results rather than by hand.`, { expected: `n1:x-coredata://<store>/ICNote/p<N>` });
521
+ const [, version, id] = m;
522
+ if (version !== "n1") throw new PreconditionError(`Unknown note ref version "${version}". This server issues "n1:" refs.`);
523
+ const pk = CORE_DATA_ID.exec(id ?? "")?.[1];
524
+ if (!pk) throw new PreconditionError(`Note ref "${raw}" does not carry a Core Data note id.`);
525
+ return {
526
+ id,
527
+ primaryKey: Number(pk)
528
+ };
529
+ };
530
+ /** Build a ref from an index row, which only ever knows the primary key. */
531
+ const refFromPrimaryKey = (storeUuid, primaryKey) => encodeRef(`x-coredata://${storeUuid}/ICNote/p${primaryKey}`);
532
+ //#endregion
533
+ //#region src/client/protobuf.ts
534
+ /**
535
+ * Minimal protobuf wire-format reader, enough to pull the body text out of a
536
+ * `ZICNOTEDATA.ZDATA` blob.
537
+ *
538
+ * ## Why it does not hardcode Apple's schema
539
+ *
540
+ * The usual approach compiles Apple's unpublished `.proto` and reads
541
+ * `document.note.note_text` by field number. That breaks the first time Apple
542
+ * renumbers a field, and this project already budgets for the schema drifting
543
+ * on every macOS release.
544
+ *
545
+ * So this walks the wire format structurally, and the path is *measured* rather
546
+ * than assumed — see `NOTE_TEXT_PATH`.
547
+ *
548
+ * Wire format, for reference:
549
+ * tag = varint; field = tag >> 3; wire type = tag & 7
550
+ * 0 varint · 1 fixed64 · 2 length-delimited · 5 fixed32 · 3/4 groups (legacy)
551
+ *
552
+ * Pure and I/O-free: callers gunzip, callers decide what to do with the result.
553
+ */
554
+ /** Depth cap. Notes nests a handful deep; anything past this is a misparse. */
555
+ const MAX_DEPTH = 8;
556
+ /** Below this, a "string" is more likely coincidence than text. */
557
+ const MIN_TEXT_LENGTH = 2;
558
+ /** Above this share of control characters, the bytes are not text. */
559
+ const MAX_CONTROL_RATIO = .05;
560
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
561
+ /** Tab, newline and carriage return are text; the rest of C0 and DEL are not. */
562
+ const isControl = (code) => code < 32 && code !== 9 && code !== 10 && code !== 13 || code === 127;
563
+ /** Read a base-128 varint. Returns null rather than throwing on a truncated one. */
564
+ const readVarint = (buf, pos) => {
565
+ let value = 0;
566
+ let shift = 0;
567
+ let p = pos;
568
+ while (p < buf.length) {
569
+ const byte = buf[p];
570
+ p += 1;
571
+ if (shift > 45) return null;
572
+ value += (byte & 127) * 2 ** shift;
573
+ if ((byte & 128) === 0) return {
574
+ value,
575
+ next: p
576
+ };
577
+ shift += 7;
578
+ }
579
+ return null;
580
+ };
581
+ /**
582
+ * Split a buffer into wire-format fields. Returns null if the bytes are not a
583
+ * well-formed message ending exactly on the boundary — that "exactly" is what
584
+ * makes this usable as a test for whether a blob is a nested message.
585
+ */
586
+ const parseFields = (buf, start = 0, end = buf.length) => {
587
+ const fields = [];
588
+ let p = start;
589
+ while (p < end) {
590
+ const tag = readVarint(buf, p);
591
+ if (!tag) return null;
592
+ const field = Math.floor(tag.value / 8);
593
+ const wire = tag.value % 8;
594
+ if (field === 0) return null;
595
+ p = tag.next;
596
+ if (wire === 0) {
597
+ const v = readVarint(buf, p);
598
+ if (!v) return null;
599
+ fields.push({
600
+ field,
601
+ wire,
602
+ value: v.value
603
+ });
604
+ p = v.next;
605
+ } else if (wire === 2) {
606
+ const len = readVarint(buf, p);
607
+ if (!len) return null;
608
+ const from = len.next;
609
+ const to = from + len.value;
610
+ if (to > end) return null;
611
+ fields.push({
612
+ field,
613
+ wire,
614
+ from,
615
+ to
616
+ });
617
+ p = to;
618
+ } else if (wire === 1 || wire === 5) {
619
+ const width = wire === 1 ? 8 : 4;
620
+ if (p + width > end) return null;
621
+ fields.push({
622
+ field,
623
+ wire
624
+ });
625
+ p += width;
626
+ } else return null;
627
+ }
628
+ return p === end ? fields : null;
629
+ };
630
+ /** Valid UTF-8 that is mostly not control characters. */
631
+ const asText = (bytes) => {
632
+ let text;
633
+ try {
634
+ text = utf8.decode(bytes);
635
+ } catch {
636
+ return null;
637
+ }
638
+ if (text.length < MIN_TEXT_LENGTH) return null;
639
+ let controls = 0;
640
+ for (let i = 0; i < text.length; i += 1) if (isControl(text.charCodeAt(i))) controls += 1;
641
+ return controls / text.length > MAX_CONTROL_RATIO ? null : text;
642
+ };
643
+ /**
644
+ * Every string in the blob, with the field path that reached it.
645
+ *
646
+ * A length-delimited field can be both a valid message and valid text, so both
647
+ * readings are followed. That ambiguity is the normal case, not an error.
648
+ */
649
+ const collectStrings = (buf, maxDepth = MAX_DEPTH) => {
650
+ const out = [];
651
+ const walk = (start, end, path, depth) => {
652
+ if (depth > maxDepth) return;
653
+ const fields = parseFields(buf, start, end);
654
+ if (!fields) return;
655
+ for (const f of fields) {
656
+ if (f.wire !== 2 || f.from === void 0 || f.to === void 0) continue;
657
+ const here = [...path, f.field];
658
+ const text = asText(buf.subarray(f.from, f.to));
659
+ if (text) out.push({
660
+ path: here.join("."),
661
+ text,
662
+ length: text.length
663
+ });
664
+ walk(f.from, f.to, here, depth + 1);
665
+ }
666
+ };
667
+ walk(0, buf.length, [], 0);
668
+ return out;
669
+ };
670
+ /**
671
+ * Where the body lives: `document(2) -> note(3) -> note_text(2)`.
672
+ *
673
+ * Measured, not assumed. Running the structural walk over 53 real notes and
674
+ * checking each result against the same note's Apple Events plaintext, every
675
+ * note read from this path matched exactly (53/53), and picking the longest
676
+ * string instead matched only 27/53 — on the other 26 an attribute-run field
677
+ * outran the body by ~60 characters. That failure is silent: it returns
678
+ * plausible, wrong text rather than an error.
679
+ */
680
+ const NOTE_TEXT_PATH = "2.3.2";
681
+ /**
682
+ * The note body.
683
+ *
684
+ * Reads the pinned path first and falls back to the longest string only if that
685
+ * path is absent, reporting which route it took so drift is detectable rather
686
+ * than silently wrong.
687
+ */
688
+ const extractNoteText = (buf, preferPath = NOTE_TEXT_PATH) => {
689
+ const candidates = collectStrings(buf);
690
+ const pinned = preferPath ? candidates.find((c) => c.path === preferPath) ?? null : null;
691
+ const longest = candidates.toSorted((a, b) => b.length - a.length)[0] ?? null;
692
+ const best = pinned ?? longest;
693
+ return {
694
+ text: best?.text ?? null,
695
+ path: best?.path ?? null,
696
+ via: best === null ? null : pinned ? "pinned" : "longest",
697
+ candidateCount: candidates.length
698
+ };
699
+ };
700
+ //#endregion
701
+ //#region src/client/store.ts
702
+ /**
703
+ * Read-only access to `NoteStore.sqlite`.
704
+ *
705
+ * Everything here is measured against a real store — see `docs/notes.md`. Three
706
+ * facts do most of the work:
707
+ *
708
+ * 1. **`Z_ENT` alone is not "a note the user sees".** On the probed library it
709
+ * matched 1191 rows against 921 notes reported by Apple Events, and the two
710
+ * deletion columns explained none of the difference. `ZTITLE1 IS NOT NULL`
711
+ * reconciles them **exactly** — verified by comparing `Z_PK` sets, not counts.
712
+ * 2. **No column holds the body.** `ZTITLE1` is the title (100% coverage) and
713
+ * `ZWIDGETSNIPPET` a ~86-character preview; the text lives in a gzipped
714
+ * protobuf in `ZICNOTEDATA.ZDATA`.
715
+ * 3. **Dates are Core Data seconds**, offset 978307200.
716
+ */
717
+ /** Every column this module reads, so a missing one is named rather than thrown at. */
718
+ const REQUIRED_COLUMNS = [
719
+ "Z_PK",
720
+ "Z_ENT",
721
+ "ZTITLE1",
722
+ "ZMODIFICATIONDATE1",
723
+ "ZCREATIONDATE1"
724
+ ];
725
+ const OPTIONAL = {
726
+ snippet: "ZSNIPPET",
727
+ widgetSnippet: "ZWIDGETSNIPPET",
728
+ folder: "ZFOLDER",
729
+ locked: "ZISPASSWORDPROTECTED"
730
+ };
731
+ const introspect = (db) => {
732
+ const cols = columnsOf(db, "ZICCLOUDSYNCINGOBJECT");
733
+ const missing = REQUIRED_COLUMNS.filter((c) => !cols.includes(c));
734
+ let noteEnt = null;
735
+ try {
736
+ noteEnt = db.prepare("SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME = 'ICNote'").get()?.Z_ENT ?? null;
737
+ } catch {
738
+ noteEnt = null;
739
+ }
740
+ if (noteEnt === null) missing.push("Z_PRIMARYKEY row for ICNote");
741
+ let storeUuid = null;
742
+ try {
743
+ storeUuid = db.prepare("SELECT Z_UUID FROM Z_METADATA LIMIT 1").get()?.Z_UUID ?? null;
744
+ } catch {
745
+ storeUuid = null;
746
+ }
747
+ return {
748
+ fingerprint: fingerprintSchema(db),
749
+ storeUuid,
750
+ noteEnt,
751
+ has: {
752
+ title: cols.includes("ZTITLE1"),
753
+ snippet: cols.includes(OPTIONAL.snippet),
754
+ widgetSnippet: cols.includes(OPTIONAL.widgetSnippet),
755
+ folder: cols.includes(OPTIONAL.folder),
756
+ locked: cols.includes(OPTIONAL.locked)
757
+ },
758
+ deletionColumns: cols.filter((c) => /^Z(ISRECOVERINGFROMTRASH|MARKEDFORDELETION)$/.test(c)),
759
+ missing
760
+ };
761
+ };
762
+ const assertUsable = (caps) => {
763
+ if (caps.missing.length) throw new SchemaDriftError(`NoteStore.sqlite is not the schema this server knows how to read (fingerprint ${caps.fingerprint}). Missing: ${caps.missing.join(", ")}. The index lane is unavailable; the Apple Events lane still works.`);
764
+ };
765
+ const toIso = (coreDataSeconds) => {
766
+ if (coreDataSeconds === null || !Number.isFinite(coreDataSeconds)) return null;
767
+ return (/* @__PURE__ */ new Date((coreDataSeconds + CORE_DATA_EPOCH_OFFSET) * 1e3)).toISOString();
768
+ };
769
+ var NoteStore = class {
770
+ #db;
771
+ caps;
772
+ mode;
773
+ constructor(db, caps, mode) {
774
+ this.#db = db;
775
+ this.caps = caps;
776
+ this.mode = mode;
777
+ }
778
+ /**
779
+ * The predicate for "a note the user sees".
780
+ *
781
+ * `Z_ENT` picks note rows; `ZTITLE1 IS NOT NULL` drops the title-less rows
782
+ * that Apple Events does not report. Verified by set equality against the
783
+ * 921 ids Apple Events returned — a matching count would not have been
784
+ * evidence, as the first ZDATA decoder demonstrated.
785
+ */
786
+ #where() {
787
+ return `o.Z_ENT = ? AND o.ZTITLE1 IS NOT NULL${this.caps.deletionColumns.map((c) => ` AND (o."${c}" IS NULL OR o."${c}" = 0)`).join("")}`;
788
+ }
789
+ #select() {
790
+ return `SELECT o.Z_PK AS pk, o.ZTITLE1 AS title, ${this.caps.has.widgetSnippet ? "o.ZWIDGETSNIPPET" : this.caps.has.snippet ? "o.ZSNIPPET" : "NULL"} AS snippet,
791
+ o.ZMODIFICATIONDATE1 AS modified, o.ZCREATIONDATE1 AS created,
792
+ ${this.caps.has.folder ? "o.ZFOLDER" : "NULL"} AS folderPk, ${this.caps.has.locked ? "o.ZISPASSWORDPROTECTED" : "0"} AS locked
793
+ FROM ZICCLOUDSYNCINGOBJECT o`;
794
+ }
795
+ #toRow(r) {
796
+ return {
797
+ primaryKey: r.pk,
798
+ title: r.title ?? null,
799
+ snippet: r.snippet ?? null,
800
+ modified: toIso(r.modified ?? null),
801
+ created: toIso(r.created ?? null),
802
+ folderPk: r.folderPk ?? null,
803
+ locked: Boolean(r.locked)
804
+ };
805
+ }
806
+ count() {
807
+ return this.#db.prepare(`SELECT COUNT(*) AS c FROM ZICCLOUDSYNCINGOBJECT o WHERE ${this.#where()}`).get(this.caps.noteEnt).c;
808
+ }
809
+ /**
810
+ * Title and snippet search.
811
+ *
812
+ * Deliberately not full text: no indexed column carries the body. Callers that
813
+ * need body matching combine this with `bodyOf` or fall back to the Apple
814
+ * Events bulk scan.
815
+ */
816
+ search(filters) {
817
+ const clauses = [this.#where()];
818
+ const params = [this.caps.noteEnt];
819
+ if (filters.query) {
820
+ const like = `%${escapeLike(filters.query)}%`;
821
+ const snippetCol = this.caps.has.widgetSnippet ? "o.ZWIDGETSNIPPET" : this.caps.has.snippet ? "o.ZSNIPPET" : null;
822
+ clauses.push(snippetCol ? `(o.ZTITLE1 LIKE ? ESCAPE '\\' OR ${snippetCol} LIKE ? ESCAPE '\\')` : `o.ZTITLE1 LIKE ? ESCAPE '\\'`);
823
+ params.push(like);
824
+ if (snippetCol) params.push(like);
825
+ }
826
+ if (filters.folderPk !== void 0 && this.caps.has.folder) {
827
+ clauses.push("o.ZFOLDER = ?");
828
+ params.push(filters.folderPk);
829
+ }
830
+ if (filters.modifiedAfter) {
831
+ clauses.push("o.ZMODIFICATIONDATE1 >= ?");
832
+ params.push(Date.parse(filters.modifiedAfter) / 1e3 - CORE_DATA_EPOCH_OFFSET);
833
+ }
834
+ if (filters.modifiedBefore) {
835
+ clauses.push("o.ZMODIFICATIONDATE1 <= ?");
836
+ params.push(Date.parse(filters.modifiedBefore) / 1e3 - CORE_DATA_EPOCH_OFFSET);
837
+ }
838
+ const sql = `${this.#select()} WHERE ${clauses.join(" AND ")}
839
+ ORDER BY o.ZMODIFICATIONDATE1 DESC LIMIT ? OFFSET ?`;
840
+ params.push(filters.limit, filters.offset);
841
+ return this.#db.prepare(sql).all(...params).map((r) => this.#toRow(r));
842
+ }
843
+ byPrimaryKey(pk) {
844
+ const row = this.#db.prepare(`${this.#select()} WHERE ${this.#where()} AND o.Z_PK = ?`).get(this.caps.noteEnt, pk);
845
+ return row ? this.#toRow(row) : null;
846
+ }
847
+ /**
848
+ * The note body, decoded from the gzipped protobuf.
849
+ *
850
+ * Returns null for password-protected notes: their `ZDATA` is AES ciphertext,
851
+ * flagged by a non-null `ZCRYPTOTAG`. No permission makes that readable.
852
+ */
853
+ bodyOf(pk) {
854
+ const row = this.#db.prepare("SELECT ZDATA AS data, ZCRYPTOTAG AS tag FROM ZICNOTEDATA WHERE ZNOTE = ?").get(pk);
855
+ if (!row?.data) return {
856
+ text: null,
857
+ via: null,
858
+ encrypted: false
859
+ };
860
+ if (row.tag) return {
861
+ text: null,
862
+ via: null,
863
+ encrypted: true
864
+ };
865
+ const blob = Buffer.from(row.data);
866
+ if (blob[0] !== 31 || blob[1] !== 139) return {
867
+ text: null,
868
+ via: null,
869
+ encrypted: false
870
+ };
871
+ let inflated;
872
+ try {
873
+ inflated = gunzipSync(blob);
874
+ } catch {
875
+ return {
876
+ text: null,
877
+ via: null,
878
+ encrypted: false
879
+ };
880
+ }
881
+ const decoded = extractNoteText(inflated);
882
+ return {
883
+ text: decoded.text,
884
+ via: decoded.via,
885
+ encrypted: false
886
+ };
887
+ }
888
+ close() {
889
+ this.#db.close();
890
+ }
891
+ };
892
+ const openStore = (path, mode, logger) => {
893
+ const { db, mode: opened, validated: caps } = openReadOnly(path, mode, {
894
+ envVar: "APPLE_NOTES_INDEX_MODE",
895
+ label: "Notes' store",
896
+ hint: "If this is a permission error, grant Full Disk Access to the app running this server.",
897
+ validate: (conn) => {
898
+ const found = introspect(conn);
899
+ assertUsable(found);
900
+ return found;
901
+ },
902
+ fatal: (err) => err instanceof SchemaDriftError,
903
+ onFallback: () => logger?.warn?.("opened the store with immutable=1; results may omit notes still in the -wal")
904
+ });
905
+ return new NoteStore(db, caps, opened);
906
+ };
907
+ //#endregion
908
+ //#region src/client/notes.ts
909
+ /**
910
+ * The Notes client.
911
+ *
912
+ * Two lanes, and which one answers depends on what was granted:
913
+ *
914
+ * - **Apple Events** — accounts, folders, every mutation, and bodies. Always the
915
+ * authority: after a write, the result is what Notes re-read.
916
+ * - **Index** — `NoteStore.sqlite`, for search and metadata at a scale Apple
917
+ * Events cannot reach.
918
+ *
919
+ * The discipline the measurements impose is in `jxa/core.ts`: bulk array fetches
920
+ * only, never `whose`, never a property read per note.
921
+ */
922
+ var AppleNotesClient = class {
923
+ config;
924
+ runner;
925
+ #logger;
926
+ #store = null;
927
+ #storeTried = false;
928
+ #bodies = null;
929
+ constructor(opts) {
930
+ this.config = opts.config;
931
+ this.#logger = opts.logger;
932
+ this.runner = opts.osascript ?? createOsascriptRunner({
933
+ osascriptPath: opts.config.osascriptPath,
934
+ timeoutMs: opts.config.osascriptTimeoutMs,
935
+ surface: NOTES_SURFACE,
936
+ logger: opts.logger
937
+ });
938
+ }
939
+ locate() {
940
+ return locateStore({ storePath: this.config.storePath });
941
+ }
942
+ /** Open the index once and remember the outcome, including the failure. */
943
+ index() {
944
+ if (this.#storeTried) return this.#store;
945
+ this.#storeTried = true;
946
+ if (this.config.indexMode === "off") return null;
947
+ try {
948
+ this.#store = openStore(this.locate().storePath, this.config.indexMode, this.#logger);
949
+ } catch (err) {
950
+ this.#logger?.debug?.("index lane unavailable", err);
951
+ this.#store = null;
952
+ }
953
+ return this.#store;
954
+ }
955
+ /**
956
+ * Probe both lanes.
957
+ *
958
+ * Apple Events is probed twice with a gap: the first event is what triggers
959
+ * the Automation prompt, and it can return failure while the user is still
960
+ * deciding — reporting "denied" then would be wrong.
961
+ */
962
+ async lanes() {
963
+ let applescript = "unavailable";
964
+ for (let attempt = 0; attempt < 2; attempt += 1) try {
965
+ await this.runner.run(LIST_ACCOUNTS);
966
+ applescript = "live";
967
+ break;
968
+ } catch (err) {
969
+ this.#logger?.debug?.("applescript probe failed", err);
970
+ if (attempt === 0) await new Promise((r) => setTimeout(r, 400));
971
+ }
972
+ const located = this.locate();
973
+ const store = this.index();
974
+ return {
975
+ applescript,
976
+ index: this.config.indexMode === "off" ? "disabled" : store ? "live" : "unavailable",
977
+ indexMode: store?.mode ?? null,
978
+ storeFingerprint: store?.caps.fingerprint ?? null,
979
+ reason: store ? null : located.reason
980
+ };
981
+ }
982
+ #allowed(accountName) {
983
+ const list = this.config.accounts;
984
+ if (!list.length) return true;
985
+ return Boolean(accountName && list.some((a) => a.toLowerCase() === accountName.toLowerCase()));
986
+ }
987
+ async accounts() {
988
+ return (await withBusyRetry(() => this.runner.run(LIST_ACCOUNTS))).filter((a) => this.#allowed(a.name));
989
+ }
990
+ async folders() {
991
+ return (await withBusyRetry(() => this.runner.run(LIST_FOLDERS))).filter((f) => this.#allowed(f.accountName));
992
+ }
993
+ /**
994
+ * Note metadata.
995
+ *
996
+ * Prefers the index — it holds the same fields, costs no Apple Event, and
997
+ * keeps working as a library grows past the point where a bulk fetch stops
998
+ * being instant.
999
+ */
1000
+ async listNotes(opts) {
1001
+ const store = this.index();
1002
+ if (store?.caps.storeUuid) {
1003
+ const folderPk = opts.folder ? await this.#folderPk(opts.folder) : void 0;
1004
+ return store.search({
1005
+ ...folderPk === void 0 ? {} : { folderPk },
1006
+ limit: opts.limit,
1007
+ offset: 0
1008
+ }).map((r) => this.#fromIndex(r, store.caps.storeUuid));
1009
+ }
1010
+ return this.#bulkNotes(opts);
1011
+ }
1012
+ async #folderPk(_name) {}
1013
+ #fromIndex(row, storeUuid) {
1014
+ return {
1015
+ ref: refFromPrimaryKey(storeUuid, row.primaryKey),
1016
+ title: row.title,
1017
+ snippet: row.snippet,
1018
+ folder: null,
1019
+ account: null,
1020
+ modified: row.modified,
1021
+ created: row.created,
1022
+ locked: row.locked,
1023
+ source: "index"
1024
+ };
1025
+ }
1026
+ async #bulkNotes(opts) {
1027
+ return (await withBusyRetry(() => this.runner.run(BULK_NOTES))).notes.filter((n) => this.#allowed(n.account)).filter((n) => !opts.folder || n.folder === opts.folder).slice(0, Math.min(opts.limit, this.config.degradedMaxNotes)).map((n) => ({
1028
+ ref: encodeRef(n.id),
1029
+ title: n.name,
1030
+ snippet: null,
1031
+ folder: n.folder,
1032
+ account: n.account,
1033
+ modified: n.modified,
1034
+ created: n.created,
1035
+ locked: n.locked,
1036
+ source: "apple-events"
1037
+ }));
1038
+ }
1039
+ /**
1040
+ * Body text for every note, cached.
1041
+ *
1042
+ * A TTL rather than invalidation, deliberately: checking whether the cache is
1043
+ * stale costs *more* over Apple Events (fetching ids and modification dates,
1044
+ * 128ms) than simply redoing the scan (97ms). There is no cheap freshness
1045
+ * check to be had, so bounded staleness is the honest trade.
1046
+ */
1047
+ async #allBodies() {
1048
+ const now = Date.now();
1049
+ if (this.#bodies && now - this.#bodies.at < this.config.searchCacheTtlMs) return this.#bodies;
1050
+ const store = this.index();
1051
+ const texts = /* @__PURE__ */ new Map();
1052
+ if (store?.caps.storeUuid) {
1053
+ const uuid = store.caps.storeUuid;
1054
+ for (const row of store.search({
1055
+ limit: 1e5,
1056
+ offset: 0
1057
+ })) {
1058
+ const body = store.bodyOf(row.primaryKey);
1059
+ if (body.text) texts.set(refFromPrimaryKey(uuid, row.primaryKey), body.text);
1060
+ }
1061
+ this.#bodies = {
1062
+ at: now,
1063
+ source: "index",
1064
+ texts
1065
+ };
1066
+ return this.#bodies;
1067
+ }
1068
+ const bulk = await withBusyRetry(() => this.runner.run(BULK_PLAINTEXT));
1069
+ bulk.ids.forEach((id, i) => {
1070
+ const text = bulk.texts[i];
1071
+ if (text) texts.set(encodeRef(String(id)), String(text));
1072
+ });
1073
+ this.#bodies = {
1074
+ at: now,
1075
+ source: "apple-events",
1076
+ texts
1077
+ };
1078
+ return this.#bodies;
1079
+ }
1080
+ /**
1081
+ * Search.
1082
+ *
1083
+ * `title` uses the index's title and snippet columns, which is a SQL query.
1084
+ * `full` needs the body, which no indexed column carries — so it decodes the
1085
+ * gzipped protobuf when the index is available and falls back to the Apple
1086
+ * Events bulk scan when it is not. Either way the match happens in JS over a
1087
+ * cached corpus, because `whose` is 6.9x slower than fetching and filtering.
1088
+ */
1089
+ async searchNotes(opts) {
1090
+ const store = this.index();
1091
+ if (opts.scope === "title" && store?.caps.storeUuid) return {
1092
+ notes: store.search({
1093
+ query: opts.query,
1094
+ limit: opts.limit,
1095
+ offset: opts.offset
1096
+ }).map((r) => this.#fromIndex(r, store.caps.storeUuid)),
1097
+ source: "index",
1098
+ scope: "title+snippet"
1099
+ };
1100
+ const corpus = await this.#allBodies();
1101
+ const needle = opts.query.toLowerCase();
1102
+ const hits = [];
1103
+ for (const [ref, text] of corpus.texts) if (text.toLowerCase().includes(needle)) hits.push(ref);
1104
+ const page = hits.slice(opts.offset, opts.offset + opts.limit);
1105
+ return {
1106
+ notes: (await Promise.all(page.map((ref) => this.getNote(ref, { body: false })))).map((s) => s.summary),
1107
+ source: corpus.source,
1108
+ scope: "full-text"
1109
+ };
1110
+ }
1111
+ async getNote(ref, opts = {}) {
1112
+ const decoded = decodeRef(ref);
1113
+ const store = this.index();
1114
+ if (store?.caps.storeUuid) {
1115
+ const row = store.byPrimaryKey(decoded.primaryKey);
1116
+ if (row) {
1117
+ const summary = this.#fromIndex(row, store.caps.storeUuid);
1118
+ if (opts.body === false) return {
1119
+ summary,
1120
+ body: null,
1121
+ bodySource: null
1122
+ };
1123
+ if (row.locked) throw new NoteLockedError(ref);
1124
+ const body = store.bodyOf(decoded.primaryKey);
1125
+ if (body.encrypted) throw new NoteLockedError(ref);
1126
+ if (body.text !== null) return {
1127
+ summary,
1128
+ body: this.#truncate(body.text),
1129
+ bodySource: `index:${body.via}`
1130
+ };
1131
+ }
1132
+ }
1133
+ const [found] = await withBusyRetry(() => this.runner.run(GET_NOTE_BODIES, { ids: [decoded.id] }));
1134
+ if (!found?.found) throw new NoteNotFoundError(ref);
1135
+ if (found.locked) throw new NoteLockedError(ref);
1136
+ return {
1137
+ summary: {
1138
+ ref,
1139
+ title: found.name,
1140
+ snippet: null,
1141
+ folder: null,
1142
+ account: null,
1143
+ modified: found.modified,
1144
+ created: found.created,
1145
+ locked: false,
1146
+ source: "apple-events"
1147
+ },
1148
+ body: found.plaintext === null ? null : this.#truncate(found.plaintext),
1149
+ bodySource: "apple-events"
1150
+ };
1151
+ }
1152
+ #truncate(text) {
1153
+ const max = this.config.bodyMaxBytes;
1154
+ if (Buffer.byteLength(text, "utf8") <= max) return text;
1155
+ return `${text.slice(0, max)}\n\n[truncated at ${max} bytes; raise APPLE_NOTES_BODY_MAX_BYTES]`;
1156
+ }
1157
+ async attachments(ref) {
1158
+ const decoded = decodeRef(ref);
1159
+ return withBusyRetry(() => this.runner.run(LIST_ATTACHMENTS, { id: decoded.id }));
1160
+ }
1161
+ /**
1162
+ * Save an attachment's bytes.
1163
+ *
1164
+ * The scripting dictionary exposes `name`, `id`, `URL` and `content
1165
+ * identifier` but **no filesystem path**, so the bytes can only come from the
1166
+ * media directory — which is Full Disk Access territory. Without it this
1167
+ * reports what is missing rather than failing obscurely.
1168
+ */
1169
+ async saveAttachment(ref, attachmentId, targetDir, opts = {}) {
1170
+ const located = this.locate();
1171
+ if (!located.readable) throw new PreconditionError("Attachment bytes need Full Disk Access: the scripting dictionary carries no file path for attachments, so they can only be read from the Notes media directory. Call apple_notes_diagnostics for the exact pane to open.");
1172
+ const meta = (await this.attachments(ref)).find((a) => a.id === attachmentId);
1173
+ if (!meta) throw new PreconditionError(`No attachment ${attachmentId} on note ${ref}.`);
1174
+ const source = this.#findMedia(located.mediaRoot, attachmentId, meta.name);
1175
+ if (!source) throw new PreconditionError(`Found the attachment's metadata but no file for it under ${located.mediaRoot}.`);
1176
+ const root = resolve(this.config.attachmentDir);
1177
+ const dir = targetDir ? resolve(root, targetDir) : root;
1178
+ if (dir !== root && !dir.startsWith(root + sep)) throw new PreconditionError(`Refusing to write outside ${root}. Set APPLE_NOTES_ATTACHMENT_DIR to change the destination.`);
1179
+ const name = basename(meta.name ?? basename(source));
1180
+ const target = resolve(join(dir, name));
1181
+ if (target !== join(dir, name) || !target.startsWith(dir + sep)) throw new PreconditionError(`Refusing to write outside ${dir}.`);
1182
+ if (existsSync(target) && !opts.overwrite) throw new PreconditionError(`${target} already exists; refusing to overwrite it.`);
1183
+ const bytes = readFileSync(source);
1184
+ mkdirSync(dir, { recursive: true });
1185
+ writeFileSync(target, bytes, { mode: 384 });
1186
+ return {
1187
+ path: target,
1188
+ bytes: bytes.length
1189
+ };
1190
+ }
1191
+ /** Bounded walk for a file whose directory is named after the attachment id. */
1192
+ #findMedia(root, attachmentId, name, depth = 0) {
1193
+ if (depth > 5) return null;
1194
+ let entries;
1195
+ try {
1196
+ entries = readdirSync(root);
1197
+ } catch {
1198
+ return null;
1199
+ }
1200
+ for (const entry of entries) {
1201
+ const full = join(root, entry);
1202
+ let isDir = false;
1203
+ try {
1204
+ isDir = statSync(full).isDirectory();
1205
+ } catch {
1206
+ continue;
1207
+ }
1208
+ if (isDir) {
1209
+ if (entry === attachmentId) {
1210
+ const inner = (() => {
1211
+ try {
1212
+ return readdirSync(full);
1213
+ } catch {
1214
+ return [];
1215
+ }
1216
+ })();
1217
+ const pick = name ? inner.find((f) => f === name) ?? inner[0] : inner[0];
1218
+ if (pick) return join(full, pick);
1219
+ }
1220
+ const found = this.#findMedia(full, attachmentId, name, depth + 1);
1221
+ if (found) return found;
1222
+ }
1223
+ }
1224
+ return null;
1225
+ }
1226
+ async createNote(opts) {
1227
+ this.#bodies = null;
1228
+ return withBusyRetry(() => this.runner.run(CREATE_NOTE, opts));
1229
+ }
1230
+ async updateNote(ref, body, mode) {
1231
+ this.#bodies = null;
1232
+ const decoded = decodeRef(ref);
1233
+ return withBusyRetry(() => this.runner.run(UPDATE_NOTE, {
1234
+ id: decoded.id,
1235
+ body,
1236
+ mode
1237
+ }));
1238
+ }
1239
+ async moveNote(ref, folderId) {
1240
+ this.#bodies = null;
1241
+ const decoded = decodeRef(ref);
1242
+ return withBusyRetry(() => this.runner.run(MOVE_NOTE, {
1243
+ id: decoded.id,
1244
+ folderId
1245
+ }));
1246
+ }
1247
+ async deleteNotes(refs) {
1248
+ this.#bodies = null;
1249
+ const ids = refs.map((r) => decodeRef(r).id);
1250
+ return withBusyRetry(() => this.runner.run(DELETE_NOTES, { ids }));
1251
+ }
1252
+ };
1253
+ //#endregion
1254
+ //#region src/config.ts
1255
+ /**
1256
+ * Configuration is environment-only — this server holds no secret at all, its
1257
+ * access is the macOS permission the user granted.
1258
+ *
1259
+ * `allowWrites`, `debug`, `osascriptPath`, `osascriptTimeoutMs` and `maxResults`
1260
+ * come from `BaseConfigSchema`.
1261
+ */
1262
+ const ConfigSchema = BaseConfigSchema.extend({
1263
+ /**
1264
+ * Account allowlist (names or ids). Empty means every account.
1265
+ *
1266
+ * `allowWrites` gates mutation, but the larger blast radius is *reading* a
1267
+ * person's whole notebook, so this is the read-side control.
1268
+ */
1269
+ accounts: z.array(z.string().min(1)).default([]),
1270
+ /** Explicit NoteStore.sqlite path. Bypasses discovery — for tests and forensic copies. */
1271
+ storePath: z.string().optional(),
1272
+ indexMode: z.enum([
1273
+ "auto",
1274
+ "ro",
1275
+ "immutable",
1276
+ "off"
1277
+ ]).default("auto"),
1278
+ /**
1279
+ * Cap for the Apple Events listing lane. Generous compared to Mail's because
1280
+ * a bulk property fetch here is one round trip at ~0.1ms per note, not 42ms.
1281
+ */
1282
+ degradedMaxNotes: z.number().int().min(1).max(1e3).default(200),
1283
+ bodyMaxBytes: z.number().int().min(1024).max(1e7).default(262144),
1284
+ /**
1285
+ * The confinement boundary for save_attachment, not merely its default: the
1286
+ * tool's `directory` argument may select a subdirectory of this, never escape
1287
+ * it. Enforced lexically with `resolve()` + `basename()`, not `realpath()` —
1288
+ * the destination may not exist yet.
1289
+ */
1290
+ attachmentDir: z.string().default(join(homedir(), "Downloads")),
1291
+ /**
1292
+ * How long the Apple Events plaintext cache stays warm.
1293
+ *
1294
+ * Measured: fetching ids + modification dates to check freshness costs *more*
1295
+ * (128ms) than simply re-scanning every body (97ms), so there is no cheap
1296
+ * invalidation to be had. A TTL with bounded staleness is the only sensible
1297
+ * trade when the index lane is unavailable.
1298
+ */
1299
+ searchCacheTtlMs: z.number().int().min(0).max(36e5).default(6e4)
1300
+ }).strict();
1301
+ /**
1302
+ * `env` is a parameter with a default so tests are hermetic — they pass their
1303
+ * own object rather than mutating (and having to restore) process.env.
1304
+ */
1305
+ const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
1306
+ allowWrites: parseBool(env.APPLE_NOTES_ALLOW_WRITES),
1307
+ debug: parseBool(env.APPLE_NOTES_DEBUG),
1308
+ accounts: parseList(env.APPLE_NOTES_ACCOUNTS),
1309
+ storePath: trimmed(env.APPLE_NOTES_STORE),
1310
+ indexMode: trimmed(env.APPLE_NOTES_INDEX_MODE),
1311
+ osascriptPath: trimmed(env.APPLE_NOTES_OSASCRIPT_PATH),
1312
+ osascriptTimeoutMs: parseIntOpt(env.APPLE_NOTES_OSASCRIPT_TIMEOUT_MS),
1313
+ maxResults: parseIntOpt(env.APPLE_NOTES_MAX_RESULTS),
1314
+ degradedMaxNotes: parseIntOpt(env.APPLE_NOTES_DEGRADED_MAX_NOTES),
1315
+ bodyMaxBytes: parseIntOpt(env.APPLE_NOTES_BODY_MAX_BYTES),
1316
+ attachmentDir: trimmed(env.APPLE_NOTES_ATTACHMENT_DIR),
1317
+ searchCacheTtlMs: parseIntOpt(env.APPLE_NOTES_SEARCH_CACHE_TTL_MS)
1318
+ });
1319
+ //#endregion
1320
+ //#region src/tools/util.ts
1321
+ const noteRefArg = z.string().min(1).describe("An opaque note ref from a search or list result (looks like \"n1:x-coredata://…/ICNote/p123\"). Do not construct one by hand.");
1322
+ const noteRefsArg = z.array(z.string().min(1)).min(1).max(200).describe("Note refs from a search or list result.");
1323
+ const folderArg = z.string().optional().describe("Folder name, e.g. \"Notes\" or \"Projects\". Folders nest; give the leaf name.");
1324
+ const folderIdArg = z.string().min(1).describe("A folder id from apple_notes_list_folders.");
1325
+ //#endregion
1326
+ //#region src/tools/accounts.ts
1327
+ const registerAccountTools = (server, client) => {
1328
+ server.registerTool("apple_notes_list_accounts", {
1329
+ description: "List the Notes accounts, with how many folders and notes each holds. Start here to discover account names for the other tools.",
1330
+ inputSchema: {},
1331
+ annotations: { readOnlyHint: true }
1332
+ }, async () => wrap(() => client.accounts()));
1333
+ server.registerTool("apple_notes_list_folders", {
1334
+ description: "List folders across every account. Folders nest, so each entry carries its depth and the account it belongs to. Use the returned id with the move and create tools.",
1335
+ inputSchema: {},
1336
+ annotations: { readOnlyHint: true }
1337
+ }, async () => wrap(() => client.folders()));
1338
+ };
1339
+ //#endregion
1340
+ //#region src/tools/actions.ts
1341
+ /**
1342
+ * The mutating tools.
1343
+ *
1344
+ * Registered only when writes are enabled, so with the flag off they are
1345
+ * invisible to the model rather than merely refused.
1346
+ */
1347
+ const registerActionTools = (server, client) => {
1348
+ server.registerTool("apple_notes_create_note", {
1349
+ description: "Create a note. Notes takes its title from the first line of the body, so `title` is prepended as a heading rather than set as a property.",
1350
+ inputSchema: {
1351
+ title: z.string().optional().describe("Becomes the first line, and therefore the title."),
1352
+ body: z.string().optional().describe("Body content. Simple HTML is accepted."),
1353
+ folderId: z.string().optional().describe("Target folder id. Defaults to the first account's default folder.")
1354
+ }
1355
+ }, async ({ title, body, folderId }) => wrap(() => client.createNote({
1356
+ title,
1357
+ body,
1358
+ folderId
1359
+ })));
1360
+ server.registerTool("apple_notes_update_note", {
1361
+ description: "Replace or append to a note's body. Editing the first line changes the title, because that is where Notes takes it from.",
1362
+ inputSchema: {
1363
+ ref: noteRefArg,
1364
+ body: z.string().min(1).describe("The new content. Simple HTML is accepted."),
1365
+ mode: z.enum(["replace", "append"]).optional().describe("replace overwrites the body (default). append adds to the end.")
1366
+ }
1367
+ }, async ({ ref, body, mode }) => wrap(() => client.updateNote(ref, body, mode ?? "replace")));
1368
+ server.registerTool("apple_notes_move_note", {
1369
+ description: "Move a note to another folder.",
1370
+ inputSchema: {
1371
+ ref: noteRefArg,
1372
+ folderId: folderIdArg
1373
+ }
1374
+ }, async ({ ref, folderId }) => wrap(() => client.moveNote(ref, folderId)));
1375
+ server.registerTool("apple_notes_delete_notes", {
1376
+ description: "Delete notes. They go to Recently Deleted rather than being destroyed, but this still requires an explicit confirm.",
1377
+ inputSchema: {
1378
+ refs: noteRefsArg,
1379
+ confirm: confirmArg
1380
+ }
1381
+ }, async ({ refs }) => wrap(() => client.deleteNotes(refs)));
1382
+ };
1383
+ //#endregion
1384
+ //#region src/tools/diagnostics.ts
1385
+ /**
1386
+ * One tool that answers "why is this not working".
1387
+ *
1388
+ * Lanes are probed before anything else is read, because the first Apple Event
1389
+ * is what triggers the Automation prompt: reading accounts first and probing
1390
+ * afterwards makes the two disagree whenever that first event loses the race.
1391
+ */
1392
+ const registerDiagnosticsTools = (server, client, ctx) => {
1393
+ server.registerTool("apple_notes_diagnostics", {
1394
+ description: "Report which lanes are live, which macOS permissions are granted, and what each missing one is blocking. Start here when a tool returns degraded or fails — it names the exact System Settings pane to open.",
1395
+ inputSchema: {},
1396
+ annotations: { readOnlyHint: true }
1397
+ }, async () => wrap(async () => {
1398
+ const lanes = await client.lanes();
1399
+ const located = client.locate();
1400
+ const accounts = lanes.applescript === "live" ? await client.accounts() : [];
1401
+ const fullDiskAccess = located.readable ? "granted" : located.exists ? "denied" : "unknown (no store found)";
1402
+ return {
1403
+ server: { lanes },
1404
+ permissions: {
1405
+ automation: lanes.applescript === "live" ? "granted" : "denied-or-notes-not-running",
1406
+ fullDiskAccess,
1407
+ ...located.readable ? {} : { howToGrant: [
1408
+ "System Settings > Privacy & Security > Full Disk Access",
1409
+ "Add the app that launches this server (Terminal, iTerm, VS Code, Claude...), then restart it.",
1410
+ "Granting it to Notes.app does nothing — the reader needs the permission, not Notes."
1411
+ ] }
1412
+ },
1413
+ store: {
1414
+ path: located.storePath,
1415
+ exists: located.exists,
1416
+ readable: located.readable,
1417
+ sizeBytes: located.size,
1418
+ walPresent: located.walPresent,
1419
+ walSizeBytes: located.walSizeBytes,
1420
+ fingerprint: lanes.storeFingerprint,
1421
+ reason: located.reason
1422
+ },
1423
+ accounts,
1424
+ settings: {
1425
+ allowWrites: ctx.allowWrites,
1426
+ accountAllowlist: client.config.accounts,
1427
+ indexMode: client.config.indexMode,
1428
+ maxResults: client.config.maxResults,
1429
+ searchCacheTtlMs: client.config.searchCacheTtlMs,
1430
+ attachmentDir: client.config.attachmentDir
1431
+ },
1432
+ caveats: [
1433
+ "Without Full Disk Access the index lane is unavailable: search falls back to an Apple Events bulk scan, which is fine on a small library and degrades linearly.",
1434
+ "Password-protected notes are encrypted at rest; no permission makes them readable.",
1435
+ "Attachment bytes need Full Disk Access — the scripting dictionary carries no file path."
1436
+ ]
1437
+ };
1438
+ }).then((r) => r ?? ok({})));
1439
+ };
1440
+ //#endregion
1441
+ //#region src/tools/notes.ts
1442
+ const registerNoteTools = (server, client, allowWrites) => {
1443
+ server.registerTool("apple_notes_list_notes", {
1444
+ description: "List notes, newest first. Returns a `ref` per note for the read and action tools. Prefers Notes' own index, which stays fast as a library grows.",
1445
+ inputSchema: {
1446
+ folder: folderArg,
1447
+ limit: limitArg
1448
+ },
1449
+ annotations: { readOnlyHint: true }
1450
+ }, async ({ folder, limit }) => wrap(() => client.listNotes({
1451
+ folder,
1452
+ limit: limit ?? Math.min(25, client.config.maxResults)
1453
+ })));
1454
+ server.registerTool("apple_notes_search_notes", {
1455
+ description: "Search notes. `scope: \"full\"` (the default) matches the body text; `scope: \"title\"` matches only titles and previews and is the cheaper query. Returns a `ref` per hit.",
1456
+ inputSchema: {
1457
+ query: z.string().min(1).describe("Text to look for. Case-insensitive substring match."),
1458
+ scope: z.enum(["full", "title"]).optional().describe("full = search body text (default). title = titles and previews only, which is a single indexed query and cheaper on a large library."),
1459
+ limit: limitArg,
1460
+ offset: z.number().int().min(0).optional().describe("Skip this many results, for paging.")
1461
+ },
1462
+ annotations: { readOnlyHint: true }
1463
+ }, async ({ query, scope, limit, offset }) => wrap(() => client.searchNotes({
1464
+ query,
1465
+ scope: scope ?? "full",
1466
+ limit: limit ?? Math.min(25, client.config.maxResults),
1467
+ offset: offset ?? 0
1468
+ })));
1469
+ server.registerTool("apple_notes_get_note", {
1470
+ description: "Read one note's full text by ref. Password-protected notes report as locked rather than returning an empty body — their text is encrypted at rest.",
1471
+ inputSchema: {
1472
+ ref: noteRefArg,
1473
+ format: z.enum(["text", "json"]).optional().describe("text returns the body as-is (default). json wraps it with the metadata.")
1474
+ },
1475
+ annotations: { readOnlyHint: true }
1476
+ }, async ({ ref, format }) => wrapResult(async () => {
1477
+ const result = await client.getNote(ref);
1478
+ if ((format ?? "text") === "text" && result.body !== null) return okText(result.body);
1479
+ return ok(result);
1480
+ }));
1481
+ server.registerTool("apple_notes_list_attachments", {
1482
+ description: "List a note's attachments. Metadata only — retrieving the bytes needs Full Disk Access, because the scripting dictionary carries no file path.",
1483
+ inputSchema: { ref: noteRefArg },
1484
+ annotations: { readOnlyHint: true }
1485
+ }, async ({ ref }) => wrap(() => client.attachments(ref)));
1486
+ if (!allowWrites) return;
1487
+ server.registerTool("apple_notes_save_attachment", {
1488
+ description: "Save one attachment to disk. Needs Full Disk Access. It can only write into APPLE_NOTES_ATTACHMENT_DIR (default ~/Downloads) and will not overwrite an existing file unless you ask it to. Write-gated because it puts a file on the user's disk, even though it changes nothing in Notes.",
1489
+ inputSchema: {
1490
+ ref: noteRefArg,
1491
+ attachmentId: z.string().min(1).describe("An id from apple_notes_list_attachments."),
1492
+ directory: z.string().optional().describe("Override the target directory. Must still resolve inside the configured one."),
1493
+ overwrite: z.boolean().optional().describe("Replace an existing file. Default false.")
1494
+ },
1495
+ annotations: {
1496
+ readOnlyHint: false,
1497
+ destructiveHint: false,
1498
+ idempotentHint: false
1499
+ }
1500
+ }, async ({ ref, attachmentId, directory, overwrite }) => wrap(() => client.saveAttachment(ref, attachmentId, directory, overwrite ? { overwrite } : {})));
1501
+ };
1502
+ //#endregion
1503
+ //#region src/tools/index.ts
1504
+ /**
1505
+ * Register the Apple Notes tools.
1506
+ *
1507
+ * The registered set is a pure function of `allowWrites` and nothing else. In
1508
+ * particular it does NOT vary with whether Full Disk Access is granted: that is
1509
+ * a runtime condition which can change while the process lives, and MCP clients
1510
+ * cache the tool list, so a tool that appears and disappears would leave clients
1511
+ * calling names the server no longer has. Tools that need the index instead
1512
+ * report their source, or explain what is missing.
1513
+ */
1514
+ const registerTools = (server, client, ctx) => {
1515
+ registerDiagnosticsTools(server, client, ctx);
1516
+ registerAccountTools(server, client);
1517
+ registerNoteTools(server, client, ctx.allowWrites);
1518
+ if (!ctx.allowWrites) return;
1519
+ registerActionTools(server, client);
1520
+ };
1521
+ //#endregion
1522
+ //#region src/server.ts
1523
+ const SERVER_NAME = BUILD_INFO.name;
1524
+ const SERVER_VERSION = BUILD_INFO.version;
1525
+ /**
1526
+ * Build the server. Side-effect free: it opens no connection, spawns no
1527
+ * process and reads no file, so a test can construct it freely and every
1528
+ * external dependency arrives through an option.
1529
+ */
1530
+ const createServer = (opts) => {
1531
+ const { config } = opts;
1532
+ const server = new McpServer({
1533
+ name: SERVER_NAME,
1534
+ version: SERVER_VERSION
1535
+ });
1536
+ const client = new AppleNotesClient({
1537
+ config,
1538
+ ...opts.logger ? { logger: opts.logger } : {},
1539
+ ...opts.osascript ? { osascript: opts.osascript } : {}
1540
+ });
1541
+ registerTools(server, client, { allowWrites: config.allowWrites });
1542
+ return {
1543
+ server,
1544
+ client
1545
+ };
1546
+ };
1547
+ //#endregion
1548
+ export { NoteLockedError as C, BUILD_INFO as D, NotesNotRunningError as E, NOTES_SURFACE as S, NotesBusyError as T, encodeRef as _, loadConfig as a, locateStore as b, introspect as c, collectStrings as d, extractNoteText as f, decodeRef as g, REF_VERSION as h, registerTools as i, openStore as l, readVarint as m, SERVER_VERSION as n, AppleNotesClient as o, parseFields as p, createServer as r, NoteStore as s, SERVER_NAME as t, NOTE_TEXT_PATH as u, refFromPrimaryKey as v, NoteNotFoundError as w, AppleNotesError as x, defaultStorePath as y };
1549
+
1550
+ //# sourceMappingURL=server-HLS_dM7r.js.map