apple-notes-mcp 2.8.49 → 2.9.1

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,641 @@
1
+ // apple-notes-private-helper: opt-in native helper for apple-notes-mcp.
2
+ //
3
+ // Speaks one JSON object in on stdin and one JSON object out on stdout. It
4
+ // loads Apple's private NotesShared framework at runtime, opens the Notes
5
+ // Core Data store through NotesShared's own managed object model and store
6
+ // options, and performs only the whitelisted actions in kActions below.
7
+ //
8
+ // READ-ONLY. This helper never writes. Every store it opens is opened with
9
+ // NSReadOnlyPersistentStoreOption and migration disabled,
10
+ // OpenReadOnlyContext() refuses to hand out a context whose store is not
11
+ // read-only, and no code path saves a managed object context. Write support
12
+ // (an earlier plain-text append action) was deliberately deferred by the
13
+ // maintainer until a second writer beside a running Notes.app, CRDT replica
14
+ // identity, and the iCloud upload lag are understood.
15
+ // src/services/privateHelperReadOnly.test.ts fails the build if a save or
16
+ // write path reappears here.
17
+ //
18
+ // This is UNSUPPORTED PRIVATE API. Every class and selector is resolved at
19
+ // runtime and checked before use; a missing one fails closed with
20
+ // `private_api_unavailable` instead of crashing. The helper never issues SQL,
21
+ // never spawns a shell, and never dispatches a caller-supplied selector.
22
+ //
23
+ // Build (src/services/privateHelperBuild.ts; `apple-notes-mcp setup --native-helper`):
24
+ // xcrun clang -fobjc-arc -O2 -Wall -framework Foundation -framework CoreData \
25
+ // -framework AppKit -DHELPER_SOURCE_SHA256='"<sha256 of this file>"' \
26
+ // -o apple-notes-private-helper apple-notes-private-helper.m
27
+ //
28
+ // Adding an action: write a `static NSDictionary *HandleX(NSDictionary *)`,
29
+ // list the NotesShared selectors it needs in an APIRequirement table (so
30
+ // `probe` can report it), and add one row to kActions with its name and
31
+ // allowed request keys. The dispatcher rejects any other key. Actions must
32
+ // be read-only (see above).
33
+
34
+ #import <AppKit/AppKit.h>
35
+ #import <CoreData/CoreData.h>
36
+ #import <CommonCrypto/CommonDigest.h>
37
+ #import <Foundation/Foundation.h>
38
+ #import <objc/message.h>
39
+ #import <objc/runtime.h>
40
+ #include <dlfcn.h>
41
+ #include <sys/stat.h>
42
+ #include <unistd.h>
43
+
44
+ #define PROTOCOL_VERSION 1
45
+ #define MAX_INPUT_BYTES (1024 * 1024)
46
+
47
+ #ifndef HELPER_SOURCE_SHA256
48
+ #define HELPER_SOURCE_SHA256 "unset"
49
+ #endif
50
+
51
+ static NSString *const kFrameworkPath =
52
+ @"/System/Library/PrivateFrameworks/NotesShared.framework/NotesShared";
53
+ static NSString *const kEnableEnv = @"APPLE_NOTES_MCP_ENABLE_PRIVATE";
54
+ static NSString *const kCopyStoreEnv = @"APPLE_NOTES_MCP_PRIVATE_STORE";
55
+
56
+ #pragma mark - Errors
57
+
58
+ @interface HelperError : NSException
59
+ @end
60
+ @implementation HelperError
61
+ @end
62
+
63
+ // Throwing keeps every handler linear: validation failures unwind to main(),
64
+ // which renders exactly one JSON error object.
65
+ static void Fail(NSString *code, NSString *message, NSDictionary *extra) {
66
+ NSMutableDictionary *info = [NSMutableDictionary dictionaryWithObject:code forKey:@"code"];
67
+ if (extra) [info addEntriesFromDictionary:extra];
68
+ @throw [HelperError exceptionWithName:code reason:message userInfo:info];
69
+ }
70
+
71
+ #pragma mark - Output
72
+
73
+ static void EmitAndExit(NSDictionary *object, int status) {
74
+ NSError *error = nil;
75
+ NSData *data = [NSJSONSerialization dataWithJSONObject:object
76
+ options:NSJSONWritingSortedKeys
77
+ error:&error];
78
+ if (!data) {
79
+ data = [@"{\"code\":\"internal_error\",\"message\":\"response serialization failed\","
80
+ @"\"status\":\"error\"}" dataUsingEncoding:NSUTF8StringEncoding];
81
+ status = 1;
82
+ }
83
+ fwrite(data.bytes, 1, data.length, stdout);
84
+ fputc('\n', stdout);
85
+ fflush(stdout);
86
+ exit(status);
87
+ }
88
+
89
+ static NSString *ISODate(NSDate *date) {
90
+ if (![date isKindOfClass:[NSDate class]]) return nil;
91
+ static NSISO8601DateFormatter *formatter;
92
+ if (!formatter) {
93
+ formatter = [NSISO8601DateFormatter new];
94
+ formatter.formatOptions =
95
+ NSISO8601DateFormatWithInternetDateTime | NSISO8601DateFormatWithFractionalSeconds;
96
+ }
97
+ return [formatter stringFromDate:date];
98
+ }
99
+
100
+ static id OrNull(id value) { return value ?: [NSNull null]; }
101
+
102
+ static NSString *SHA256Hex(NSData *data) {
103
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
104
+ CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
105
+ NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
106
+ for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", digest[i]];
107
+ return hex;
108
+ }
109
+
110
+ #pragma mark - Runtime API surface
111
+
112
+ // Everything the helper calls on NotesShared, grouped by the feature that
113
+ // needs it. `probe` reports each feature's missing entries; handlers call
114
+ // RequireFeature() before touching the framework.
115
+ typedef struct {
116
+ const char *cls;
117
+ const char *sel; // NULL = class only
118
+ BOOL classMethod;
119
+ } APIRequirement;
120
+
121
+ static const APIRequirement kModelAPI[] = {
122
+ {"ICPersistentContainer", "managedObjectModel", YES},
123
+ {"ICPersistentContainer", "standardStoreOptions", YES},
124
+ {"ICNote", NULL, NO},
125
+ {"ICNoteData", NULL, NO},
126
+ {"ICCloudState", NULL, NO},
127
+ };
128
+
129
+ static const APIRequirement kReadAPI[] = {
130
+ {"ICNote", "mergeableString", NO},
131
+ {"ICNote", "isDeletedOrInTrash", NO},
132
+ {"ICNote", "isSharedViaICloud", NO},
133
+ {"ICNote", "isEditable", NO},
134
+ {"ICTTMergeableString", "attributedString", NO},
135
+ };
136
+
137
+ // Core Data properties are @dynamic: their accessors do not exist until Core
138
+ // Data generates them, so they are checked against the managed object model
139
+ // (entity name, then property names) rather than with respondsToSelector:.
140
+ typedef struct {
141
+ const char *entity;
142
+ const char *properties; // comma-separated
143
+ } ModelRequirement;
144
+
145
+ static const ModelRequirement kModelProperties[] = {
146
+ {"ICNote",
147
+ "identifier,title,modificationDate,creationDate,folder,account,noteData,cloudState,"
148
+ "isPasswordProtected,markedForDeletion,needsInitialFetchFromCloud"},
149
+ {"ICNoteData", "data"},
150
+ {"ICCloudState", "currentLocalVersion,latestVersionSyncedToCloud"},
151
+ {"ICFolder", "identifier"},
152
+ };
153
+
154
+ #define COUNT(a) (sizeof(a) / sizeof((a)[0]))
155
+
156
+ static BOOL gFrameworkLoaded = NO;
157
+ static NSString *gFrameworkError = nil;
158
+
159
+ static void LoadFramework(void) {
160
+ static BOOL attempted = NO;
161
+ if (attempted) return;
162
+ attempted = YES;
163
+ if (dlopen(kFrameworkPath.fileSystemRepresentation, RTLD_NOW | RTLD_LOCAL)) {
164
+ gFrameworkLoaded = YES;
165
+ } else {
166
+ const char *reason = dlerror();
167
+ gFrameworkError = reason ? @(reason) : @"dlopen failed";
168
+ }
169
+ }
170
+
171
+ static NSArray<NSString *> *MissingAPI(const APIRequirement *list, size_t count) {
172
+ NSMutableArray *missing = [NSMutableArray array];
173
+ for (size_t i = 0; i < count; i++) {
174
+ Class cls = objc_getClass(list[i].cls);
175
+ if (!cls) {
176
+ [missing addObject:@(list[i].cls)];
177
+ continue;
178
+ }
179
+ if (!list[i].sel) continue;
180
+ SEL sel = sel_registerName(list[i].sel);
181
+ BOOL ok = list[i].classMethod ? [cls respondsToSelector:sel]
182
+ : [cls instancesRespondToSelector:sel];
183
+ if (!ok)
184
+ [missing addObject:[NSString stringWithFormat:@"%s[%s %s]", list[i].classMethod ? "+" : "-",
185
+ list[i].cls, list[i].sel]];
186
+ }
187
+ return missing;
188
+ }
189
+
190
+ static NSArray<NSString *> *MissingModelProperties(void) {
191
+ Class container = objc_getClass("ICPersistentContainer");
192
+ SEL modelSel = sel_registerName("managedObjectModel");
193
+ if (!container || ![container respondsToSelector:modelSel]) return @[ @"managed object model" ];
194
+ NSManagedObjectModel *model = ((id(*)(id, SEL))objc_msgSend)(container, modelSel);
195
+ if (![model isKindOfClass:[NSManagedObjectModel class]]) return @[ @"managed object model" ];
196
+ NSMutableArray *missing = [NSMutableArray array];
197
+ for (size_t i = 0; i < COUNT(kModelProperties); i++) {
198
+ NSEntityDescription *entity = model.entitiesByName[@(kModelProperties[i].entity)];
199
+ if (!entity) {
200
+ [missing addObject:[NSString stringWithFormat:@"entity %s", kModelProperties[i].entity]];
201
+ continue;
202
+ }
203
+ for (NSString *name in [@(kModelProperties[i].properties) componentsSeparatedByString:@","])
204
+ if (!entity.propertiesByName[name])
205
+ [missing addObject:[NSString stringWithFormat:@"%s.%@", kModelProperties[i].entity, name]];
206
+ }
207
+ return missing;
208
+ }
209
+
210
+ typedef NS_ENUM(NSInteger, Feature) { FeatureModel, FeatureRead };
211
+
212
+ static NSArray<NSString *> *MissingForFeature(Feature feature) {
213
+ LoadFramework();
214
+ if (!gFrameworkLoaded) return @[ @"NotesShared.framework" ];
215
+ NSMutableArray *missing = [NSMutableArray array];
216
+ [missing addObjectsFromArray:MissingAPI(kModelAPI, COUNT(kModelAPI))];
217
+ if (missing.count) return missing;
218
+ if (feature >= FeatureRead) {
219
+ [missing addObjectsFromArray:MissingModelProperties()];
220
+ [missing addObjectsFromArray:MissingAPI(kReadAPI, COUNT(kReadAPI))];
221
+ }
222
+ return missing;
223
+ }
224
+
225
+ static void RequireFeature(Feature feature) {
226
+ NSArray *missing = MissingForFeature(feature);
227
+ if (missing.count)
228
+ Fail(@"private_api_unavailable",
229
+ @"Required NotesShared classes or selectors are not available on this macOS",
230
+ @{@"missing" : missing});
231
+ }
232
+
233
+ // Typed wrappers around objc_msgSend. Selectors are compile-time constants.
234
+ static id Send(id target, const char *sel) {
235
+ return ((id(*)(id, SEL))objc_msgSend)(target, sel_registerName(sel));
236
+ }
237
+ static BOOL SendBool(id target, const char *sel) {
238
+ return ((BOOL(*)(id, SEL))objc_msgSend)(target, sel_registerName(sel));
239
+ }
240
+
241
+ #pragma mark - Store
242
+
243
+ typedef struct {
244
+ NSString *path;
245
+ BOOL isCopy;
246
+ } StoreLocation;
247
+
248
+ static NSString *LiveStorePath(void) {
249
+ return [NSHomeDirectory()
250
+ stringByAppendingPathComponent:
251
+ @"Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"];
252
+ }
253
+
254
+ static BOOL SameFile(NSString *a, NSString *b) {
255
+ struct stat sa, sb;
256
+ if (stat(a.fileSystemRepresentation, &sa) != 0) return NO;
257
+ if (stat(b.fileSystemRepresentation, &sb) != 0) return NO;
258
+ return sa.st_dev == sb.st_dev && sa.st_ino == sb.st_ino;
259
+ }
260
+
261
+ // The copy-store override exists so tests can read a copy. It must never
262
+ // resolve to the live database, including through a symlink or hard link.
263
+ static StoreLocation ResolveStore(void) {
264
+ NSString *override = NSProcessInfo.processInfo.environment[kCopyStoreEnv];
265
+ NSString *live = LiveStorePath();
266
+ if (override.length) {
267
+ NSString *resolved = [override stringByResolvingSymlinksInPath];
268
+ NSString *liveResolved = [live stringByResolvingSymlinksInPath];
269
+ NSString *liveDir = [liveResolved stringByDeletingLastPathComponent];
270
+ if ([resolved isEqualToString:liveResolved] || SameFile(resolved, live) ||
271
+ [resolved hasPrefix:[liveDir stringByAppendingString:@"/"]])
272
+ Fail(@"invalid_request", @"APPLE_NOTES_MCP_PRIVATE_STORE must point at a copy, not the live store",
273
+ nil);
274
+ if (![NSFileManager.defaultManager fileExistsAtPath:resolved])
275
+ Fail(@"store_unavailable", @"APPLE_NOTES_MCP_PRIVATE_STORE does not exist", nil);
276
+ return (StoreLocation){resolved, YES};
277
+ }
278
+ return (StoreLocation){live, NO};
279
+ }
280
+
281
+ // Opens NotesShared's model over the store with Notes' own store options,
282
+ // ALWAYS read-only. There is deliberately no read-write variant: the options
283
+ // force NSReadOnlyPersistentStoreOption and disable migration, and the store
284
+ // Core Data hands back is checked again before any context is returned.
285
+ static NSManagedObjectContext *OpenReadOnlyContext(StoreLocation store) {
286
+ RequireFeature(FeatureModel);
287
+ if (!store.isCopy && ![NSProcessInfo.processInfo.environment[kEnableEnv] isEqualToString:@"1"])
288
+ Fail(@"disabled", @"The private helper is disabled; set APPLE_NOTES_MCP_ENABLE_PRIVATE=1 to opt in",
289
+ nil);
290
+ if (![NSFileManager.defaultManager isReadableFileAtPath:store.path])
291
+ Fail(@"store_unavailable",
292
+ @"NoteStore.sqlite is not readable. Grant Full Disk Access to the app that launches the MCP "
293
+ @"server, then relaunch it.",
294
+ nil);
295
+ Class container = objc_getClass("ICPersistentContainer");
296
+ NSManagedObjectModel *model = Send(container, "managedObjectModel");
297
+ NSDictionary *standard = Send(container, "standardStoreOptions");
298
+ if (![model isKindOfClass:[NSManagedObjectModel class]] ||
299
+ ![standard isKindOfClass:[NSDictionary class]])
300
+ Fail(@"private_api_unavailable", @"NotesShared did not return a model and store options", nil);
301
+ NSMutableDictionary *options = [standard mutableCopy];
302
+ // Never migrate: a model/store mismatch means this helper is out of date.
303
+ options[NSMigratePersistentStoresAutomaticallyOption] = @NO;
304
+ options[NSInferMappingModelAutomaticallyOption] = @NO;
305
+ options[NSReadOnlyPersistentStoreOption] = @YES;
306
+ if (![options[NSReadOnlyPersistentStoreOption] boolValue] ||
307
+ [options[NSMigratePersistentStoresAutomaticallyOption] boolValue] ||
308
+ [options[NSInferMappingModelAutomaticallyOption] boolValue])
309
+ Fail(@"read_only_violation", @"Refusing to open the Notes store without read-only options", nil);
310
+ NSPersistentStoreCoordinator *coordinator =
311
+ [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:model];
312
+ NSError *error = nil;
313
+ NSPersistentStore *persistent =
314
+ [coordinator addPersistentStoreWithType:NSSQLiteStoreType
315
+ configuration:nil
316
+ URL:[NSURL fileURLWithPath:store.path]
317
+ options:options
318
+ error:&error];
319
+ if (!persistent)
320
+ Fail(@"store_unavailable", @"Could not open the Notes store with the NotesShared model",
321
+ @{@"detail" : error.localizedDescription ?: @"unknown"});
322
+ // Belt and braces: refuse to continue if Core Data opened any store
323
+ // read-write, whatever the options said.
324
+ for (NSPersistentStore *opened in coordinator.persistentStores) {
325
+ if (!opened.isReadOnly || ![opened.options[NSReadOnlyPersistentStoreOption] boolValue]) {
326
+ [coordinator removePersistentStore:opened error:nil];
327
+ Fail(@"read_only_violation", @"The Notes store was opened read-write; refusing to continue", nil);
328
+ }
329
+ }
330
+ NSManagedObjectContext *context =
331
+ [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
332
+ context.persistentStoreCoordinator = coordinator;
333
+ context.undoManager = nil;
334
+ return context;
335
+ }
336
+
337
+ #pragma mark - Note lookup and state
338
+
339
+ static NSRegularExpression *UUIDPattern(void) {
340
+ static NSRegularExpression *re;
341
+ if (!re)
342
+ re = [NSRegularExpression
343
+ regularExpressionWithPattern:@"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-"
344
+ @"[0-9A-Fa-f]{12}$"
345
+ options:0
346
+ error:nil];
347
+ return re;
348
+ }
349
+
350
+ static BOOL IsUUID(id value) {
351
+ return [value isKindOfClass:[NSString class]] &&
352
+ [UUIDPattern() numberOfMatchesInString:value options:0 range:NSMakeRange(0, [value length])] ==
353
+ 1;
354
+ }
355
+
356
+ static NSManagedObject *FetchNote(NSManagedObjectContext *context, NSString *identifier) {
357
+ NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"ICNote"];
358
+ request.predicate = [NSPredicate predicateWithFormat:@"identifier ==[c] %@", identifier];
359
+ request.fetchLimit = 2;
360
+ request.returnsObjectsAsFaults = NO;
361
+ NSError *error = nil;
362
+ NSArray *rows = [context executeFetchRequest:request error:&error];
363
+ if (!rows) Fail(@"store_unavailable", @"Note fetch failed", @{@"detail" : OrNull(error.localizedDescription)});
364
+ if (rows.count == 0) Fail(@"not_found", @"No note has that identifier", nil);
365
+ if (rows.count > 1) Fail(@"unsupported_note", @"More than one note row has that identifier", nil);
366
+ return rows.firstObject;
367
+ }
368
+
369
+ static NSData *NoteBodyData(NSManagedObject *note) {
370
+ id noteData = Send(note, "noteData");
371
+ if (!noteData) return nil;
372
+ id data = [noteData valueForKey:@"data"];
373
+ return [data isKindOfClass:[NSData class]] ? data : nil;
374
+ }
375
+
376
+ // Opaque change-detection token over the persisted native state: identity,
377
+ // folder, deletion/lock flags, modification date, and a digest of the
378
+ // serialized CRDT body. Any persisted edit (text, style, attachment glyph)
379
+ // changes it. It is informational only; no action accepts it back.
380
+ static NSString *RevisionToken(NSManagedObject *note) {
381
+ id folder = [note valueForKey:@"folder"];
382
+ NSData *body = NoteBodyData(note);
383
+ NSDate *modified = [note valueForKey:@"modificationDate"];
384
+ NSString *canonical = [NSString
385
+ stringWithFormat:@"r1\x1f%@\x1f%@\x1f%d\x1f%d\x1f%.6f\x1f%@",
386
+ [note valueForKey:@"identifier"] ?: @"",
387
+ folder ? ([folder valueForKey:@"identifier"] ?: @"") : @"",
388
+ [[note valueForKey:@"markedForDeletion"] boolValue],
389
+ SendBool(note, "isPasswordProtected"),
390
+ modified ? modified.timeIntervalSinceReferenceDate : 0.0,
391
+ body ? SHA256Hex(body) : @"none"];
392
+ return [@"r1:" stringByAppendingString:SHA256Hex([canonical dataUsingEncoding:NSUTF8StringEncoding])];
393
+ }
394
+
395
+ // The note's visible text. On macOS 27 the mergeable string is an
396
+ // ICTTMergeableAttributedString whose `-string` returns an attributed string,
397
+ // so the plain text comes from `-attributedString`.
398
+ static NSString *BodyText(id mergeableString) {
399
+ if (!mergeableString) return nil;
400
+ id attributed = Send(mergeableString, "attributedString");
401
+ return [attributed isKindOfClass:[NSAttributedString class]] ? [attributed string] : nil;
402
+ }
403
+
404
+ static BOOL NotesAppRunning(void) {
405
+ return [NSRunningApplication runningApplicationsWithBundleIdentifier:@"com.apple.Notes"].count > 0;
406
+ }
407
+
408
+ static NSDictionary *CloudSyncState(NSManagedObject *note) {
409
+ id cloud = Send(note, "cloudState");
410
+ BOOL inICloud = [note respondsToSelector:sel_registerName("isInICloudAccount")]
411
+ ? SendBool(note, "isInICloudAccount")
412
+ : NO;
413
+ if (!cloud) return @{@"inICloudAccount" : @(inICloud), @"available" : @NO};
414
+ long long current = [[cloud valueForKey:@"currentLocalVersion"] longLongValue];
415
+ long long synced = [[cloud valueForKey:@"latestVersionSyncedToCloud"] longLongValue];
416
+ return @{
417
+ @"available" : @YES,
418
+ @"inICloudAccount" : @(inICloud),
419
+ @"currentLocalVersion" : @(current),
420
+ @"latestVersionSyncedToCloud" : @(synced),
421
+ // Notes' own upload eligibility test: a local version newer than the
422
+ // last version it recorded as synced.
423
+ @"uploadPending" : @((BOOL)(current > synced)),
424
+ };
425
+ }
426
+
427
+ static NSDictionary *NoteState(NSManagedObject *note) {
428
+ id folder = [note valueForKey:@"folder"];
429
+ id account = [note valueForKey:@"account"];
430
+ BOOL locked = SendBool(note, "isPasswordProtected");
431
+ NSMutableDictionary *state = [@{
432
+ @"identifier" : OrNull([note valueForKey:@"identifier"]),
433
+ @"objectURI" : note.objectID.URIRepresentation.absoluteString,
434
+ @"title" : OrNull([note valueForKey:@"title"]),
435
+ @"modificationDate" : OrNull(ISODate([note valueForKey:@"modificationDate"])),
436
+ @"creationDate" : OrNull(ISODate([note valueForKey:@"creationDate"])),
437
+ @"folderIdentifier" : OrNull(folder ? [folder valueForKey:@"identifier"] : nil),
438
+ @"accountIdentifier" : OrNull(account ? [account valueForKey:@"identifier"] : nil),
439
+ @"passwordProtected" : @(locked),
440
+ @"deletedOrInTrash" : @(SendBool(note, "isDeletedOrInTrash")),
441
+ @"sharedViaICloud" : @(SendBool(note, "isSharedViaICloud")),
442
+ @"editable" : @(SendBool(note, "isEditable")),
443
+ @"bodyAvailable" : @((BOOL)(NoteBodyData(note) != nil)),
444
+ @"revision" : RevisionToken(note),
445
+ @"cloudSync" : CloudSyncState(note),
446
+ } mutableCopy];
447
+ if (!locked) {
448
+ id ms = Send(note, "mergeableString");
449
+ NSString *text = BodyText(ms);
450
+ if ([text isKindOfClass:[NSString class]]) state[@"bodyLengthUTF16"] = @(text.length);
451
+ }
452
+ return state;
453
+ }
454
+
455
+ #pragma mark - Request validation
456
+
457
+ static NSString *RequireString(NSDictionary *request, NSString *key) {
458
+ id value = request[key];
459
+ if (![value isKindOfClass:[NSString class]] || [value length] == 0)
460
+ Fail(@"invalid_request", [NSString stringWithFormat:@"`%@` must be a non-empty string", key], nil);
461
+ return value;
462
+ }
463
+
464
+ static NSString *RequireIdentifier(NSDictionary *request) {
465
+ NSString *identifier = RequireString(request, @"identifier");
466
+ if (!IsUUID(identifier)) Fail(@"invalid_request", @"`identifier` must be a Notes UUID", nil);
467
+ return identifier;
468
+ }
469
+
470
+ #pragma mark - Actions
471
+
472
+ static NSDictionary *HandleHello(NSDictionary *request);
473
+ static NSDictionary *HandleProbe(NSDictionary *request);
474
+ static NSDictionary *HandleReadNoteState(NSDictionary *request);
475
+
476
+ typedef struct {
477
+ const char *name;
478
+ const char *allowedKeys; // comma-separated, beyond `protocol` and `action`
479
+ NSDictionary *(*handler)(NSDictionary *);
480
+ } ActionSpec;
481
+
482
+ // The whitelist. Order is the order `hello` reports. Every action is
483
+ // read-only; there is no write action in this protocol version.
484
+ static const ActionSpec kActions[] = {
485
+ {"hello", "", HandleHello},
486
+ {"probe", "", HandleProbe},
487
+ {"read_note_state", "identifier", HandleReadNoteState},
488
+ };
489
+
490
+ static NSArray<NSString *> *ActionNames(void) {
491
+ NSMutableArray *names = [NSMutableArray array];
492
+ for (size_t i = 0; i < COUNT(kActions); i++) [names addObject:@(kActions[i].name)];
493
+ return names;
494
+ }
495
+
496
+ static NSDictionary *HandleHello(NSDictionary *request) {
497
+ (void)request;
498
+ return @{
499
+ @"status" : @"ok",
500
+ @"protocolVersion" : @(PROTOCOL_VERSION),
501
+ @"sourceSha256" : @HELPER_SOURCE_SHA256,
502
+ @"readOnly" : @YES,
503
+ @"actions" : ActionNames(),
504
+ };
505
+ }
506
+
507
+ static NSDictionary *FeatureReport(Feature feature, BOOL contextOK, NSString *contextReason) {
508
+ NSArray *missing = MissingForFeature(feature);
509
+ if (missing.count)
510
+ return @{@"available" : @NO, @"reason" : @"private_api_unavailable", @"missing" : missing};
511
+ if (!contextOK)
512
+ return @{@"available" : @NO, @"reason" : contextReason ?: @"store_unavailable", @"missing" : @[]};
513
+ return @{@"available" : @YES, @"reason" : [NSNull null], @"missing" : @[]};
514
+ }
515
+
516
+ static NSDictionary *HandleProbe(NSDictionary *request) {
517
+ (void)request;
518
+ LoadFramework();
519
+ NSOperatingSystemVersion v = NSProcessInfo.processInfo.operatingSystemVersion;
520
+ NSString *osVersion = [NSString
521
+ stringWithFormat:@"%ld.%ld.%ld", (long)v.majorVersion, (long)v.minorVersion, (long)v.patchVersion];
522
+ NSString *notesVersion = [NSBundle bundleWithPath:@"/System/Applications/Notes.app"]
523
+ .infoDictionary[@"CFBundleShortVersionString"];
524
+
525
+ BOOL contextOK = NO;
526
+ NSString *contextReason = nil;
527
+ NSString *contextDetail = nil;
528
+ NSNumber *noteCount = nil;
529
+ StoreLocation store = {nil, NO};
530
+ @try {
531
+ store = ResolveStore();
532
+ NSManagedObjectContext *context = OpenReadOnlyContext(store);
533
+ NSFetchRequest *count = [NSFetchRequest fetchRequestWithEntityName:@"ICNote"];
534
+ NSError *error = nil;
535
+ NSUInteger n = [context countForFetchRequest:count error:&error];
536
+ if (n == NSNotFound) {
537
+ contextReason = @"store_unavailable";
538
+ contextDetail = error.localizedDescription;
539
+ } else {
540
+ contextOK = YES;
541
+ noteCount = @(n);
542
+ }
543
+ } @catch (HelperError *e) {
544
+ contextReason = e.userInfo[@"code"];
545
+ contextDetail = e.reason;
546
+ }
547
+
548
+ return @{
549
+ @"status" : @"ok",
550
+ @"protocolVersion" : @(PROTOCOL_VERSION),
551
+ @"sourceSha256" : @HELPER_SOURCE_SHA256,
552
+ @"readOnly" : @YES,
553
+ @"os" : @{@"version" : osVersion, @"notesAppVersion" : OrNull(notesVersion)},
554
+ @"framework" : @{@"loaded" : @(gFrameworkLoaded), @"error" : OrNull(gFrameworkError)},
555
+ @"store" : @{
556
+ @"kind" : store.path ? (store.isCopy ? @"copy" : @"live") : [NSNull null],
557
+ @"opened" : @(contextOK),
558
+ @"reason" : OrNull(contextReason),
559
+ @"detail" : OrNull(contextDetail),
560
+ @"noteRows" : OrNull(noteCount),
561
+ },
562
+ @"syncHostRunning" : @(NotesAppRunning()),
563
+ @"features" : @{
564
+ @"readNoteState" : FeatureReport(FeatureRead, contextOK, contextReason),
565
+ },
566
+ };
567
+ }
568
+
569
+ static NSDictionary *HandleReadNoteState(NSDictionary *request) {
570
+ NSString *identifier = RequireIdentifier(request);
571
+ RequireFeature(FeatureRead);
572
+ NSManagedObjectContext *context = OpenReadOnlyContext(ResolveStore());
573
+ NSManagedObject *note = FetchNote(context, identifier);
574
+ NSMutableDictionary *result = [NoteState(note) mutableCopy];
575
+ result[@"status"] = @"ok";
576
+ result[@"syncHostRunning"] = @(NotesAppRunning());
577
+ return result;
578
+ }
579
+
580
+ #pragma mark - Main
581
+
582
+ static NSData *ReadStdin(void) {
583
+ NSMutableData *data = [NSMutableData data];
584
+ char buffer[65536];
585
+ size_t n;
586
+ while ((n = fread(buffer, 1, sizeof buffer, stdin)) > 0) {
587
+ [data appendBytes:buffer length:n];
588
+ if (data.length > MAX_INPUT_BYTES) Fail(@"input_too_large", @"Request exceeds 1 MiB", nil);
589
+ }
590
+ return data;
591
+ }
592
+
593
+ static NSDictionary *Dispatch(void) {
594
+ NSData *input = ReadStdin();
595
+ if (input.length == 0) Fail(@"invalid_json", @"Empty request", nil);
596
+ NSError *error = nil;
597
+ id parsed = [NSJSONSerialization JSONObjectWithData:input options:0 error:&error];
598
+ if (![parsed isKindOfClass:[NSDictionary class]])
599
+ Fail(@"invalid_json", @"Request must be one JSON object", nil);
600
+ NSDictionary *request = parsed;
601
+ id protocol = request[@"protocol"];
602
+ if (![protocol isKindOfClass:[NSNumber class]] || [protocol integerValue] != PROTOCOL_VERSION)
603
+ Fail(@"protocol_mismatch",
604
+ [NSString stringWithFormat:@"This helper speaks protocol %d", PROTOCOL_VERSION],
605
+ @{@"protocolVersion" : @(PROTOCOL_VERSION)});
606
+ id action = request[@"action"];
607
+ if (![action isKindOfClass:[NSString class]]) Fail(@"invalid_request", @"`action` is required", nil);
608
+ for (size_t i = 0; i < COUNT(kActions); i++) {
609
+ if (![action isEqualToString:@(kActions[i].name)]) continue;
610
+ NSMutableSet *allowed = [NSMutableSet setWithArray:@[ @"protocol", @"action" ]];
611
+ NSString *extra = @(kActions[i].allowedKeys);
612
+ if (extra.length) [allowed addObjectsFromArray:[extra componentsSeparatedByString:@","]];
613
+ for (NSString *key in request)
614
+ if (![allowed containsObject:key])
615
+ Fail(@"invalid_request", [NSString stringWithFormat:@"Unknown request field `%@`", key], nil);
616
+ return kActions[i].handler(request);
617
+ }
618
+ Fail(@"unknown_action", @"Action is not in the whitelist", @{@"actions" : ActionNames()});
619
+ return nil;
620
+ }
621
+
622
+ int main(void) {
623
+ @autoreleasepool {
624
+ @try {
625
+ EmitAndExit(Dispatch(), 0);
626
+ } @catch (HelperError *e) {
627
+ NSMutableDictionary *out = [e.userInfo mutableCopy];
628
+ out[@"status"] = @"error";
629
+ out[@"message"] = e.reason ?: @"error";
630
+ EmitAndExit(out, 1);
631
+ } @catch (NSException *e) {
632
+ EmitAndExit(@{
633
+ @"status" : @"error",
634
+ @"code" : @"internal_error",
635
+ @"message" : [NSString stringWithFormat:@"%@: %@", e.name, e.reason ?: @""],
636
+ },
637
+ 1);
638
+ }
639
+ }
640
+ return 1;
641
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.8.49",
3
+ "version": "2.9.1",
4
4
  "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "build",
13
13
  "docs",
14
+ "native",
14
15
  "shortcuts",
15
16
  "README.md",
16
17
  "LICENSE"