apple-notes-mcp 2.8.49 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -1
- package/build/index.js +679 -33
- package/native/private-helper/apple-notes-private-helper.m +641 -0
- package/package.json +2 -1
|
@@ -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.
|
|
3
|
+
"version": "2.9.0",
|
|
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"
|