@mgcrea/mcp-apple-messages 1.3.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.
@@ -0,0 +1,806 @@
1
+ import { AppleAutomationError, AppleAutomationError as AppleMessagesError, BuildInfo, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, Logger, OsascriptRunner, ReadOnlyMode, SchemaDriftError, StoreFacts, SurfaceContext } from "@mgcrea/mcp-apple-core";
2
+ import { AppleContactsClient } from "@mgcrea/mcp-apple-contacts";
3
+ import { z } from "zod";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ //#region src/build-info.d.ts
7
+ declare const BUILD_INFO: BuildInfo;
8
+ //#endregion
9
+ //#region src/config.d.ts
10
+ /**
11
+ * Configuration is environment-only — this server holds no secret at all, its
12
+ * access is the macOS permission the user granted.
13
+ *
14
+ * `allowWrites` is inherited from `BaseConfigSchema` and, since 1.2.0, means
15
+ * something here: it gates the one mutating tool, `send_message`. With it off
16
+ * this server registers no write tool and therefore sends no Apple Event at all,
17
+ * which is what keeps its "no Automation grant" claim true by default.
18
+ *
19
+ * `docs/messages.md` recorded why sending went unprobed for so long — "probing
20
+ * it would mean sending a real message to a real person" — and left the id
21
+ * bridge open, because Apple Events returns no chat identifier to reconcile
22
+ * against. `sendReconcileMs` is that decision made: the file lane finds the row
23
+ * instead.
24
+ */
25
+ declare const ConfigSchema: z.ZodObject<{
26
+ allowWrites: z.ZodDefault<z.ZodBoolean>;
27
+ exposePrompts: z.ZodDefault<z.ZodBoolean>;
28
+ debug: z.ZodDefault<z.ZodBoolean>;
29
+ osascriptPath: z.ZodDefault<z.ZodString>;
30
+ osascriptTimeoutMs: z.ZodDefault<z.ZodNumber>;
31
+ maxResults: z.ZodDefault<z.ZodNumber>;
32
+ storePath: z.ZodOptional<z.ZodString>;
33
+ indexMode: z.ZodDefault<z.ZodEnum<{
34
+ auto: "auto";
35
+ immutable: "immutable";
36
+ off: "off";
37
+ ro: "ro";
38
+ }>>;
39
+ resolveContacts: z.ZodDefault<z.ZodBoolean>;
40
+ attachmentDir: z.ZodDefault<z.ZodString>;
41
+ defaultRangeDays: z.ZodDefault<z.ZodNumber>;
42
+ sendReconcileMs: z.ZodDefault<z.ZodNumber>;
43
+ }, z.core.$strict>;
44
+ type Config = z.infer<typeof ConfigSchema>;
45
+ declare const loadConfig: (env?: NodeJS.ProcessEnv) => Config;
46
+ //#endregion
47
+ //#region src/client/locate.d.ts
48
+ /**
49
+ * Find Messages' store.
50
+ *
51
+ * The easiest locator in the repo: `~/Library/Messages/chat.db` is a constant,
52
+ * with no generated directory name to list (Reminders) and no per-account fan-out
53
+ * (Contacts). `statSync` succeeds on a TCC-protected file while `open` is denied,
54
+ * so this distinguishes "not there" from "not allowed" with no permission at all
55
+ * — which is most of what diagnostics is for on a surface where the grant is
56
+ * mandatory.
57
+ */
58
+ declare const STORE_RELATIVE: string;
59
+ /** Attachment bytes live here, referenced by `attachment.filename`. */
60
+ declare const ATTACHMENTS_RELATIVE: string;
61
+ type LocateResult = StoreFacts & {
62
+ storePath: string;
63
+ attachmentsPath: string;
64
+ /** Nothing outside this is copied out, whatever the store says. */
65
+ messagesRoot: string;
66
+ reason: string | null;
67
+ };
68
+ declare const defaultStorePath: (home?: string) => string;
69
+ declare const locateStore: (opts?: {
70
+ storePath?: string | undefined;
71
+ home?: string;
72
+ }) => LocateResult;
73
+ //#endregion
74
+ //#region src/client/store.d.ts
75
+ declare const reactionLabel: (type: number) => string;
76
+ type StoreCapabilities = {
77
+ fingerprint: string;
78
+ messageColumns: Set<string>;
79
+ chatColumns: Set<string>;
80
+ handleColumns: Set<string>;
81
+ attachmentColumns: Set<string>;
82
+ hasAttachments: boolean;
83
+ hasReactions: boolean;
84
+ hasThreads: boolean;
85
+ hasEdits: boolean;
86
+ };
87
+ type MessageRow = {
88
+ guid: string;
89
+ chatGuid: string | null;
90
+ chatName: string | null;
91
+ handle: string | null;
92
+ service: string | null;
93
+ isFromMe: boolean;
94
+ /** Apple-seconds. Render with `renderInstant`. */
95
+ sentAt: number | null;
96
+ readAt: number | null;
97
+ deliveredAt: number | null;
98
+ editedAt: number | null;
99
+ text: string | null;
100
+ /** Which lane answered — the column, or the decoder. */
101
+ textSource: "column" | "decoded" | "none";
102
+ subject: string | null;
103
+ isRead: boolean;
104
+ isSent: boolean;
105
+ isDelivered: boolean;
106
+ hasAttachments: boolean;
107
+ /** Set when this row is a tapback on another message rather than a message. */
108
+ reactionType: number | null;
109
+ reactionTarget: string | null;
110
+ /** Set when this message is a reply in a thread. */
111
+ threadOriginator: string | null;
112
+ /** Non-zero for a group event — someone joined, left, or renamed the chat. */
113
+ itemType: number | null;
114
+ };
115
+ type ChatRow = {
116
+ guid: string;
117
+ identifier: string | null;
118
+ displayName: string | null;
119
+ /** 43 is a group chat, 45 is one-to-one. Reported raw beside the boolean. */
120
+ style: number | null;
121
+ isGroup: boolean;
122
+ service: string | null;
123
+ participants: string[];
124
+ messages: number;
125
+ lastMessageAt: number | null;
126
+ };
127
+ type RangeQuery = {
128
+ chatGuid?: string | undefined;
129
+ fromApple?: number | undefined;
130
+ toApple?: number | undefined;
131
+ includeReactions?: boolean | undefined;
132
+ limit: number;
133
+ };
134
+ declare class MessagesStore {
135
+ #private;
136
+ readonly db: DatabaseSync;
137
+ readonly mode: string;
138
+ readonly caps: StoreCapabilities;
139
+ constructor(db: DatabaseSync, mode: string, caps: StoreCapabilities);
140
+ /**
141
+ * A window of messages, newest first.
142
+ *
143
+ * Reactions are excluded by default. They are rows in this table like any
144
+ * other, and 2,788 of them would otherwise appear as messages reading
145
+ * `Liked "see you at 8"` — which is not something anybody typed.
146
+ */
147
+ range(q: RangeQuery): MessageRow[];
148
+ /**
149
+ * Text search, in two passes — and the second one is the point.
150
+ *
151
+ * Pass 1 is `LIKE` on the column: measured at **16 ms over 97,416 rows** with
152
+ * 27 existing indexes, so there is no index-vs-scan tradeoff to litigate and
153
+ * no FTS table to build.
154
+ *
155
+ * Pass 2 covers what pass 1 structurally cannot. 3,051 messages have an empty
156
+ * `text` and content only in `attributedBody`, and no amount of SQL reaches
157
+ * inside a blob. Decoding them costs about **6 ms** — the decoder runs at
158
+ * 2 ms per thousand — so completeness here is nearly free, and a search that
159
+ * silently omitted one message in thirty-two would be the worst kind of wrong.
160
+ */
161
+ search(query: string, limit: number, includeReactions?: boolean): MessageRow[];
162
+ byGuid(guid: string): MessageRow | null;
163
+ /** Tapbacks aimed at one message. */
164
+ reactionsFor(guid: string): {
165
+ type: number;
166
+ label: string;
167
+ handle: string | null;
168
+ }[];
169
+ /**
170
+ * The attachments on one message.
171
+ *
172
+ * `id` is `attachment.guid`, for the reason `ref.ts` gives at length about
173
+ * messages: the ROWID is faster and gets REUSED. Every "delete this
174
+ * conversation" frees a block of attachment ids for the next insert, so a
175
+ * caller that listed attachments in one turn and saved one two turns later
176
+ * would write out a different file, silently. The guid is `UNIQUE NOT NULL`
177
+ * in the shipped schema and is what Apple's own sync joins on.
178
+ *
179
+ * `path` is the raw `filename` column, reported so a caller can see WHERE the
180
+ * bytes are before asking for them — it is frequently `~`-prefixed, and it is
181
+ * empty for an attachment iCloud has offloaded.
182
+ */
183
+ attachmentsFor(guid: string): {
184
+ id: string | null;
185
+ path: string | null;
186
+ mimeType: string | null;
187
+ transferName: string | null;
188
+ bytes: number | null;
189
+ isSticker: boolean;
190
+ }[];
191
+ /** One attachment by its guid, wherever it hangs. Null when it is gone. */
192
+ attachmentById(id: string): {
193
+ id: string | null;
194
+ path: string | null;
195
+ mimeType: string | null;
196
+ transferName: string | null;
197
+ bytes: number | null;
198
+ } | null;
199
+ chats(limit: number): ChatRow[];
200
+ /** One chat, by the guid a ref carries. Null when it has been deleted. */
201
+ chatByGuid(guid: string): ChatRow | null;
202
+ /**
203
+ * The chats that already exist with a set of handles, newest first.
204
+ *
205
+ * This is what makes a send addressable at all. Messages will not enumerate
206
+ * participants for a script, so the write lane cannot look a person up — but
207
+ * it can address a chat by guid, and the guid lives here. The read lane
208
+ * choosing the target for the write lane is the whole arrangement; see
209
+ * `client/jxa/core.ts`.
210
+ *
211
+ * Handles are matched as given. Suffix matching happens a layer up in
212
+ * `client/messages.ts`, where `packages/contacts`' measured `suffixKey` is
213
+ * available and the candidate list is the store's own 1,075 handles.
214
+ */
215
+ chatsForHandles(handles: readonly string[], limit?: number): ChatRow[];
216
+ /**
217
+ * Outgoing messages in a set of chats since an instant — the send's receipt.
218
+ *
219
+ * `docs/messages.md` recorded that Apple Events returns no chat identifier, so
220
+ * a send "cannot report what it wrote by id". That is true of the write lane
221
+ * alone and false of the pair: the row Messages writes for an outgoing message
222
+ * is an ordinary row in this table, and a narrow window plus the target chat
223
+ * identifies it. Matching on text as well would be wrong — two identical
224
+ * messages a minute apart are a normal thing to send — so the caller passes a
225
+ * `sinceApple` taken immediately BEFORE the send and takes the oldest match.
226
+ */
227
+ sentSince(chatGuids: readonly string[], sinceApple: number, limit?: number): MessageRow[];
228
+ /** Every distinct handle in the store, for a bulk resolve. */
229
+ handles(): string[];
230
+ counts(): {
231
+ messages: number;
232
+ chats: number;
233
+ handles: number;
234
+ attachments: number;
235
+ };
236
+ close(): void;
237
+ }
238
+ declare const introspect: (db: DatabaseSync) => StoreCapabilities;
239
+ declare const openStore: (path: string | null, mode: ReadOnlyMode, logger?: Logger) => MessagesStore | null;
240
+ //#endregion
241
+ //#region src/client/messages.d.ts
242
+ /**
243
+ * Messages' one lane, plus the resolver.
244
+ *
245
+ * ## Why this depends on another surface
246
+ *
247
+ * `chat.db` records a correspondent as `+15551234567` and nothing else, so a
248
+ * server built on it alone answers *"+15551234567 said …"* — complete, and
249
+ * useless. `packages/contacts` exists because of that, and this is the consumer
250
+ * it was built for. It is a workspace import rather than an MCP call: reaching a
251
+ * function in the same repo through a protocol would be absurd.
252
+ *
253
+ * `docs/contacts.md` measured what to expect, and the numbers set the contract:
254
+ *
255
+ * | denominator | resolved |
256
+ * | ----------------------------- | -------- |
257
+ * | every handle ever seen | 27.6% |
258
+ * | messages in the last year | 97.6% |
259
+ * | the 25 busiest correspondents | 84% |
260
+ *
261
+ * So **`unknown` is a normal outcome, not an error.** About one in six of even
262
+ * the busiest correspondents does not resolve, and a caller that treats that as
263
+ * a failure will be wrong several times on any real inbox. Every rendered
264
+ * correspondent therefore carries both the resolved name and the raw handle.
265
+ *
266
+ * ## Degrading
267
+ *
268
+ * If Contacts cannot be read — its own TCC grant, separate from Full Disk
269
+ * Access, and one the user may simply not have answered — resolution is skipped
270
+ * and handles are returned raw. That is a capability downgrade, reported through
271
+ * `diagnostics`, never a throw: a Messages server with no names is still a
272
+ * Messages server.
273
+ */
274
+ type CreateClientOptions = {
275
+ config: Config;
276
+ logger?: Logger;
277
+ /** Injected by tests. */
278
+ home?: string;
279
+ /** Injected by tests, so no test reaches the developer's real address book. */
280
+ contacts?: AppleContactsClient | null;
281
+ /** Injected by tests, so no test sends a real message to a real person. */
282
+ osascript?: OsascriptRunner;
283
+ };
284
+ /**
285
+ * What a send actually knows, which is less than a caller might assume.
286
+ *
287
+ * `sent` means Messages accepted the command without throwing. `delivered` is
288
+ * not a field here at all, because nothing in either lane reports it at send
289
+ * time. What closes the gap is `message`: the row the file lane found afterwards
290
+ * — a real ref, usable with `apple_messages_get_message` like any other.
291
+ */
292
+ type SendResult = {
293
+ sent: true;
294
+ /** Which rung of the ladder in `client/jxa/core.ts` answered. */
295
+ strategy: string;
296
+ targetKind: string;
297
+ /** The chat the file lane chose, when it could choose one. */
298
+ chatRef: string | null;
299
+ chat: string | null;
300
+ to: Correspondent | null;
301
+ /** Whether Messages had to be launched. A launch is visible to the user. */
302
+ launched: boolean;
303
+ /** `matched` | `pending` | `unavailable` — see `#reconcile`. */
304
+ reconciliation: string;
305
+ message: RenderedMessage | null;
306
+ note?: string;
307
+ };
308
+ type Correspondent = {
309
+ /** Always present. The raw `handle.id` from the store. */
310
+ handle: string | null;
311
+ /** Null when unresolved, which is normal — see above. */
312
+ name: string | null;
313
+ /** `resolved` | `unknown` | `ambiguous` | `shortcode` | `unavailable`. */
314
+ resolution: string;
315
+ };
316
+ type RenderedMessage = {
317
+ ref: string;
318
+ chatRef: string | null;
319
+ chat: string | null;
320
+ from: Correspondent;
321
+ fromMe: boolean;
322
+ sentAt: string | null;
323
+ editedAt: string | null;
324
+ text: string | null;
325
+ /** `column` | `decoded` | `none` — which lane produced the text. */
326
+ textSource: string;
327
+ subject: string | null;
328
+ service: string | null;
329
+ isRead: boolean;
330
+ hasAttachments: boolean;
331
+ /** Present only when this row is a reply. */
332
+ replyToRef?: string;
333
+ /** Present only when this row is a group event rather than a message. */
334
+ itemType?: number;
335
+ };
336
+ type RenderedChat = {
337
+ ref: string;
338
+ name: string | null;
339
+ isGroup: boolean;
340
+ service: string | null;
341
+ participants: Correspondent[];
342
+ messages: number;
343
+ lastMessageAt: string | null;
344
+ };
345
+ declare class AppleMessagesClient {
346
+ #private;
347
+ constructor(opts: CreateClientOptions);
348
+ get config(): Config;
349
+ located(): LocateResult;
350
+ store(): MessagesStore | null;
351
+ listMessages(opts: {
352
+ chatRef?: string | undefined;
353
+ fromApple?: number | undefined;
354
+ toApple?: number | undefined;
355
+ includeReactions?: boolean | undefined;
356
+ limit?: number | undefined;
357
+ }): RenderedMessage[];
358
+ searchMessages(query: string, limit?: number): RenderedMessage[];
359
+ getMessage(guid: string): (RenderedMessage & {
360
+ reactions: {
361
+ label: string;
362
+ from: Correspondent;
363
+ }[];
364
+ attachments: ReturnType<MessagesStore["attachmentsFor"]>;
365
+ }) | null;
366
+ /**
367
+ * Copy one attachment out of the Messages store onto disk.
368
+ *
369
+ * ## Why this is a copy and not an extraction
370
+ *
371
+ * Mail's equivalent has to parse MIME out of an `.emlx` because the bytes are
372
+ * inside the message file. Messages does not work that way: `attachment` rows
373
+ * point at real files under `~/Library/Messages`, so the work here is finding
374
+ * the row, deciding the path is one we are willing to read, and copying.
375
+ *
376
+ * ## Two boundaries, not one
377
+ *
378
+ * The DESTINATION boundary is the same one Mail and Notes enforce:
379
+ * `attachmentDir` is a confinement, `directory` may only select inside it, and
380
+ * the leaf name is `basename`d because it comes from whoever sent the message.
381
+ *
382
+ * The SOURCE boundary is this surface's own. `filename` comes out of a
383
+ * database this server never writes, and it is a fully-qualified path: taken
384
+ * at face value it names any file the process can read. So it is required to
385
+ * resolve inside the Messages root before a single byte is read. That check
386
+ * has never fired on a real store and is not expected to — it is here so that
387
+ * the day the schema surprises us, the surprise is a refusal.
388
+ */
389
+ saveAttachment(attachmentId: string, opts?: {
390
+ directory?: string | undefined;
391
+ overwrite?: boolean;
392
+ }): Promise<{
393
+ path: string;
394
+ bytes: number;
395
+ source: string;
396
+ mimeType: string | null;
397
+ }>;
398
+ listChats(limit?: number): RenderedChat[];
399
+ /**
400
+ * Send one message. A real one, to a real person, immediately.
401
+ *
402
+ * Everything difficult about this is in `client/jxa/core.ts`; what is left
403
+ * here is choosing the target from the file lane and reconciling afterwards.
404
+ */
405
+ sendMessage(input: {
406
+ chatRef?: string | undefined;
407
+ to?: string | undefined;
408
+ text: string;
409
+ service?: string | undefined;
410
+ }): Promise<SendResult>;
411
+ /** Bounds for a range query, as apple-seconds. */
412
+ window(from?: Date, to?: Date): {
413
+ fromApple?: number;
414
+ toApple?: number;
415
+ };
416
+ status(): {
417
+ located: LocateResult;
418
+ store: {
419
+ opened: boolean;
420
+ mode: string | null;
421
+ fingerprint: string | null;
422
+ };
423
+ counts: ReturnType<MessagesStore["counts"]> | null;
424
+ contacts: {
425
+ enabled: boolean;
426
+ available: boolean;
427
+ resolved: number;
428
+ };
429
+ };
430
+ close(): void;
431
+ }
432
+ //#endregion
433
+ //#region src/client/dates.d.ts
434
+ /**
435
+ * SQL that yields apple-SECONDS as a REAL, whichever unit the row holds.
436
+ *
437
+ * Done in SQL rather than in JS because the point is to never let the raw
438
+ * integer reach `node:sqlite`'s value conversion.
439
+ */
440
+ declare const appleSecondsSql: (column: string) => string;
441
+ /** Apple-seconds to a JS Date. Null in, null out. */
442
+ declare const fromAppleSeconds: (value: number | null) => Date | null;
443
+ /** A JS Date to apple-seconds, for range bounds. */
444
+ declare const toAppleSeconds: (date: Date) => number;
445
+ /** ISO-8601, or null. What every date field on a result carries. */
446
+ declare const renderInstant: (value: number | null) => string | null;
447
+ //#endregion
448
+ //#region src/client/errors.d.ts
449
+ declare const MESSAGES_SURFACE: SurfaceContext;
450
+ /**
451
+ * Used by exactly one code path, and unused by every read.
452
+ *
453
+ * Messages is the one surface with NO Apple Events read lane at all — measured,
454
+ * not assumed. `docs/messages.md` records every attempt failing, and the reason
455
+ * is peculiar enough to write down: Messages answers "Application isn't running"
456
+ * while `NSRunningApplication` reports it running, because it lives as a
457
+ * windowless background process that declines to wake for a script. The liveness
458
+ * check and the app's own answer disagree and neither is lying.
459
+ *
460
+ * The id is the liveness check for `send`, which is the only thing Apple Events
461
+ * can do on this surface — see `client/jxa/core.ts`.
462
+ */
463
+ declare const MESSAGES_BUNDLE_ID = "com.apple.MobileSMS";
464
+ /** A message ref no longer resolves — deleted, or the chat was cleared. */
465
+ declare class MessageNotFoundError extends AppleAutomationError {
466
+ readonly name = "MessageNotFoundError";
467
+ constructor(ref: string);
468
+ }
469
+ /** A chat ref no longer resolves. */
470
+ declare class ChatNotFoundError extends AppleAutomationError {
471
+ readonly name = "ChatNotFoundError";
472
+ constructor(ref: string);
473
+ }
474
+ /**
475
+ * The store could not be read.
476
+ *
477
+ * Its own error because this surface fails harder than any other: there is no
478
+ * Apple Events fallback, so without Full Disk Access there is no server at all.
479
+ * `docs/distribution.md`'s "try before you grant" was retired partly because of
480
+ * this surface, and the message says so rather than implying a degraded mode
481
+ * that does not exist.
482
+ */
483
+ declare class MessagesUnavailableError extends AppleAutomationError {
484
+ readonly name = "MessagesUnavailableError";
485
+ constructor(reason: string);
486
+ }
487
+ /**
488
+ * Messages would not accept any form of recipient for a send.
489
+ *
490
+ * Its own error because the cause is almost never the recipient. Every rung of
491
+ * the ladder in `client/jxa/core.ts` except the first one enumerates something,
492
+ * and enumeration is exactly what this app refuses — so the usual cause of this
493
+ * error is that the chat is new (no guid in the store to address it by) rather
494
+ * than that the person does not exist. The message says so, because "not found"
495
+ * would send a caller looking for a typo that is not there.
496
+ */
497
+ declare class SendTargetNotFoundError extends AppleAutomationError {
498
+ readonly name = "SendTargetNotFoundError";
499
+ constructor(recipient: string, attempts: readonly string[]);
500
+ }
501
+ /** Messages accepted the target and then refused the send itself. */
502
+ declare class SendFailedError extends AppleAutomationError {
503
+ readonly name = "SendFailedError";
504
+ constructor(message: string, attempts: readonly string[]);
505
+ }
506
+ //#endregion
507
+ //#region src/client/ref.d.ts
508
+ /**
509
+ * Refs for messages and chats.
510
+ *
511
+ * ## Why a GUID rather than a rowid
512
+ *
513
+ * Both exist, and the rowid is faster to look up. The GUID wins anyway because
514
+ * **rowids are reused.** SQLite hands a deleted row's id to the next insert
515
+ * unless the table is `AUTOINCREMENT`, and Messages deletes constantly — every
516
+ * "delete this conversation" frees a block of them. A ref handed to a model in
517
+ * one turn and used two turns later would then resolve to a DIFFERENT message,
518
+ * with no error anywhere. That is the failure this project keeps designing
519
+ * against: plausible, wrong, and silent.
520
+ *
521
+ * The GUID is also what Apple itself joins on — `associated_message_guid` for
522
+ * reactions, `thread_originator_guid` for replies — so it is the identifier the
523
+ * schema already treats as stable.
524
+ *
525
+ * ## Why `m1:` and `mc1:`
526
+ *
527
+ * `c1:` is Calendar's, `r1:` is Reminders', `k1:` is Contacts'. A ref that
528
+ * decodes under two surfaces would be worse than one that decodes under none,
529
+ * so each prefix is claimed once and the version digit keeps a future scheme
530
+ * change additive.
531
+ */
532
+ declare const MESSAGE_REF_VERSION = "m1";
533
+ declare const CHAT_REF_VERSION = "mc1";
534
+ declare class InvalidMessageRefError extends AppleAutomationError {
535
+ readonly name = "InvalidMessageRefError";
536
+ constructor(raw: string, want: "message" | "chat");
537
+ }
538
+ declare const encodeMessageRef: (guid: string) => string;
539
+ declare const encodeChatRef: (guid: string) => string;
540
+ declare const decodeMessageRef: (raw: string) => string;
541
+ declare const decodeChatRef: (raw: string) => string;
542
+ //#endregion
543
+ //#region src/client/typedstream.d.ts
544
+ /**
545
+ * NSArchiver `typedstream` reader — enough of it to pull the text out of a
546
+ * Messages `attributedBody` blob.
547
+ *
548
+ * ## Why this has to exist
549
+ *
550
+ * `docs/messages.md` measured it: 97,092 of 97,414 messages carry a blob and
551
+ * only 94,049 carry `text`. **The blob is the norm and `text` is the redundant
552
+ * copy**, not the other way round. 3,043 messages — one in thirty-two — have an
553
+ * empty `text` and content only in here, so a server that reads the column
554
+ * returns nothing for them, silently and with no error to notice.
555
+ *
556
+ * The archive header is `04 0B streamtyped 81 E8 03`. Not `bplist00`, not gzip,
557
+ * and not the protobuf that Notes turned out to hold, so none of the existing
558
+ * decoders apply.
559
+ *
560
+ * ## The format, as far as this reader needs it
561
+ *
562
+ * A stream of tagged values. Integers are a single signed byte unless prefixed:
563
+ *
564
+ * 0x81 int16 follows (little-endian)
565
+ * 0x82 int32 follows
566
+ * 0x83 float or double follows
567
+ * 0x84 START — a new class or object definition
568
+ * 0x85 nil / empty
569
+ * 0x86 END of the current object
570
+ * >=0x92 a back-reference; index = byte - 0x92
571
+ *
572
+ * Strings arrive length-prefixed. Class names and object contents both use that
573
+ * shape, which is why this walks structurally instead of pattern-matching: the
574
+ * same bytes mean different things depending on where you are.
575
+ *
576
+ * ## Honest failure, never a guess
577
+ *
578
+ * The Notes decoder was documented backwards because a `LIMIT 1` sample landed
579
+ * on an outlier, and this project has now been bitten three times by a heuristic
580
+ * that produced a plausible wrong answer. So there is no "longest printable run"
581
+ * fallback here. If the structure does not parse, `ok` is false and the caller
582
+ * counts it — and `text` remains available as the answer for the 96.5% of rows
583
+ * that have one.
584
+ *
585
+ * Pure and I/O-free.
586
+ *
587
+ * ## Two copies, deliberately
588
+ *
589
+ * `scripts/lib/typedstream.mjs` is the same reader, and the probe uses it to
590
+ * measure this one against a real store. They are kept in step by hand, as
591
+ * `packages/notes/src/client/protobuf.ts` and `scripts/lib/note-protobuf.mjs`
592
+ * already are: a probe that imported from a package would stop being runnable
593
+ * before that package exists, which is the wrong way round.
594
+ */
595
+ /**
596
+ * The message text.
597
+ *
598
+ * ## Text only, and that is now a measured decision rather than a shortcut
599
+ *
600
+ * `docs/messages.md` left this open: *"whether `attributedBody` carries
601
+ * formatting that matters, or is only ever a redundant copy of `text` plus
602
+ * attachment placeholders. This decides whether the decoder must preserve
603
+ * structure or merely extract a string."*
604
+ *
605
+ * It is the second. Two things settle it:
606
+ *
607
+ * 1. **Attribute values are back-REFERENCED, not inline.** Archiving an
608
+ * attachment-shaped string through the real `NSArchiver` produces
609
+ * `92 84 98 98 22 "__kIMFileTransferGUIDAttributeName" 86` — where `98` is an
610
+ * index into the object table rather than a type encoding. Resolving those
611
+ * means reconstructing the whole table, which is materially more code and
612
+ * more ways to be silently wrong.
613
+ * 2. **Nothing needs it.** The one attribute worth having is the file-transfer
614
+ * GUID, and attachments are already reachable relationally — 17,529 rows in
615
+ * their own table, joined through `message_attachment_join`. The blob's copy
616
+ * is redundant, and the placeholder character itself (U+FFFC) survives in the
617
+ * text, so the position is not lost either.
618
+ *
619
+ * So this returns the backing string and says plainly that it stopped there.
620
+ * `hasAttributes` reports whether anything followed it, which is what a future
621
+ * decision to go further would be based on — a count, not a guess.
622
+ */
623
+ type DecodedBody = {
624
+ ok: true;
625
+ text: string;
626
+ classes: string[];
627
+ /** Bytes remained after the backing store — attribute runs this does not decode. */
628
+ hasAttributes: boolean;
629
+ error: null;
630
+ } | {
631
+ ok: false;
632
+ text?: undefined;
633
+ classes?: string[];
634
+ hasAttributes?: undefined;
635
+ error: string;
636
+ };
637
+ declare const decodeAttributedBody: (buffer: Uint8Array | null | undefined) => DecodedBody;
638
+ /**
639
+ * A redacted structural outline of one blob.
640
+ *
641
+ * For measuring the format on a machine that has the data, without the report
642
+ * ever carrying a message. Every string is reduced to its length and its
643
+ * character class; only Apple's own constants survive as themselves.
644
+ */
645
+ declare const outline: (buffer: Uint8Array, maxTokens?: number) => string[];
646
+ //#endregion
647
+ //#region src/client/jxa/core.d.ts
648
+ /**
649
+ * The Apple Events lane for Messages — which exists for exactly one verb.
650
+ *
651
+ * Every script here is a static constant. None may contain a template
652
+ * interpolation: `assertStaticScript` rejects any script containing a dollar
653
+ * sign followed by a brace, including one written inside the JXA source. Use
654
+ * string concatenation in JXA code.
655
+ *
656
+ * Contract, shared with every other surface:
657
+ * - parameters arrive as `JSON.parse(argv[0])`
658
+ * - success returns `JSON.stringify({ok: true, data})`
659
+ * - an application-level failure returns `{ok: false, error: {code, message}}`
660
+ * and still exits 0, so a non-zero exit always means infrastructure.
661
+ *
662
+ * ## There is no read.ts here, and there never can be
663
+ *
664
+ * Adding a send did not add a read lane, and on this surface that is not a
665
+ * policy choice — it is the measurement in `docs/messages.md`:
666
+ *
667
+ * | attempt | result |
668
+ * | ---------------- | ----------------------------------------- |
669
+ * | `chats()` | `Error: Application isn't running.` |
670
+ * | `chats.id()` | `TypeError: M.chats.id is not a function` |
671
+ * | `participants()` | `Error: Application isn't running.` |
672
+ * | `buddies()` | `Error: Application isn't running.` |
673
+ * | messages of chat | `Error: Application isn't running.` |
674
+ *
675
+ * Messages answers "Application isn't running" while `NSRunningApplication`
676
+ * reports it running, because it lives as a windowless background process that
677
+ * declines to wake for a script. `test/jxa.test.ts` asserts `read.ts` does not
678
+ * exist, so this cannot be re-added out of helpfulness.
679
+ *
680
+ * The consequence for the code below is concrete: **the target resolution steps
681
+ * that enumerate anything are expected to fail**, which is why they are a ladder
682
+ * and why every rung reports itself. The one rung that does not enumerate —
683
+ * `chats.byId(guid)`, with the guid handed over by the file lane — is the one
684
+ * this design is built around.
685
+ *
686
+ * ## What the dictionary actually offers
687
+ *
688
+ * MEASURED from `sdef /System/Applications/Messages.app` on macOS 26.6. Three
689
+ * commands, and only one of them is a write:
690
+ *
691
+ * send text (or a file) to a participant or a chat
692
+ * login log in to all accounts
693
+ * logout log out of all accounts
694
+ *
695
+ * `login`/`logout` are not exposed as tools: logging a user out of iMessage on
696
+ * every device is not something to do behind a tool call, and there is no read
697
+ * to justify logging in.
698
+ *
699
+ * `send`'s direct parameter is typed `file` OR `text`. **This ships text only.**
700
+ * The file form is one branch away and deliberately not taken: a tool that
701
+ * transfers an arbitrary local path to a remote person is an exfiltration
702
+ * primitive, and unlike the text form its blast radius is not bounded by what
703
+ * the model can say. Recorded here so the omission reads as a decision.
704
+ *
705
+ * ## The `to` parameter, and why the file lane picks the target
706
+ *
707
+ * `send` takes a `participant` or a `chat`. Getting one of those normally means
708
+ * enumerating, which is the thing that does not work here. But the `chat` class
709
+ * carries `id` — "A guid identifier for this chat" — and the file lane already
710
+ * holds `chat.guid` for all 1,027 chats on the measured store. So the read lane
711
+ * chooses the target and the write lane addresses it by id, which is the only
712
+ * arrangement where neither lane has to do the thing it cannot.
713
+ *
714
+ * That the two guids are the same string is NOT assumed. It is the exact class
715
+ * of thing this project has been wrong about before — `docs/messages.md` calls
716
+ * the id bridge "unanswerable by construction" because Messages returned no
717
+ * identifier to bridge FROM. So `chats.byId` is rung one of a ladder, every rung
718
+ * records why it failed, and the strategy that worked is reported back to the
719
+ * caller in the tool result.
720
+ */
721
+ /** The bundle id, which does not match the display name: Messages.app is still MobileSMS. */
722
+ declare const PRELUDE = "\nObjC.import(\"AppKit\");\n\nfunction ok(data) { return JSON.stringify({ ok: true, data: data }); }\nfunction err(code, message, extra) {\n var e = { code: code, message: String(message) };\n if (extra) e.detail = extra;\n return JSON.stringify({ ok: false, error: e });\n}\n\n/** Read one property defensively — every read on this surface is allowed to fail. */\nfunction prop(fn, fallback) {\n try {\n var v = fn();\n return v === undefined ? fallback : v;\n } catch (e) {\n return fallback;\n }\n}\n\nfunction isMessagesRunning() {\n var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(\"com.apple.MobileSMS\");\n return apps.count > 0;\n}\n\n/** The dictionary's service type enumeration: SMS, iMessage, RCS. */\nfunction serviceEnum(name) {\n var s = String(name || \"\").toLowerCase();\n if (s === \"sms\") return \"SMS\";\n if (s === \"rcs\") return \"RCS\";\n return \"iMessage\";\n}\n\n/**\n * Find something `send` will accept as its `to`.\n *\n * Ordered cheapest and most-likely-to-work first. Every rung is wrapped, every\n * failure is recorded, and the caller is told which one answered — on a surface\n * whose whole read half is known broken, \"it worked\" without \"how\" is not a\n * result anyone can act on later.\n */\nfunction resolveTarget(M, p, tried) {\n var i;\n\n // 1. The chat guid the file lane read out of chat.db. No enumeration.\n if (p.chatGuid) {\n try {\n var chat = M.chats.byId(p.chatGuid);\n chat.id();\n return { target: chat, strategy: \"chat-guid\", kind: \"chat\" };\n } catch (e) {\n tried.push(\"chat-guid: \" + (e.message || e));\n }\n }\n\n if (!p.handle) return null;\n\n // 2. The guid Messages composes for a one-to-one chat, spelled the way the\n // store spells it: \"iMessage;-;+15551234567\". Constructed rather than read,\n // so it is below the real one and above everything that enumerates.\n var services = p.service ? [serviceEnum(p.service)] : [\"iMessage\", \"SMS\", \"RCS\"];\n for (i = 0; i < services.length; i++) {\n var guess = services[i] + \";-;\" + p.handle;\n try {\n var guessed = M.chats.byId(guess);\n guessed.id();\n return { target: guessed, strategy: \"chat-guid-guess\", kind: \"chat\", guid: guess };\n } catch (e2) {\n tried.push(\"chat-guid-guess(\" + guess + \"): \" + (e2.message || e2));\n }\n }\n\n // 3. A participant reached through its account. This enumerates, so it is\n // expected to fail with \"Application isn't running\" — kept because it is\n // the form every AppleScript example on the internet uses, and because if\n // launching the app does wake the scripting interface, this is what works.\n for (i = 0; i < services.length; i++) {\n try {\n var accounts = M.accounts.whose({ serviceType: services[i] })();\n for (var j = 0; j < accounts.length; j++) {\n try {\n var buddy = accounts[j].participants.whose({ handle: p.handle })()[0];\n if (buddy) {\n buddy.id();\n return { target: buddy, strategy: \"account-participant\", kind: \"participant\" };\n }\n } catch (e4) {\n tried.push(\"account-participant(\" + services[i] + \"): \" + (e4.message || e4));\n }\n }\n } catch (e3) {\n tried.push(\"accounts(\" + services[i] + \"): \" + (e3.message || e3));\n }\n }\n\n // 4. The flat participant list, last because it is the widest enumeration.\n try {\n var flat = M.participants.whose({ handle: p.handle })()[0];\n if (flat) {\n flat.id();\n return { target: flat, strategy: \"participant\", kind: \"participant\" };\n }\n tried.push(\"participant: no participant with that handle\");\n } catch (e5) {\n tried.push(\"participant: \" + (e5.message || e5));\n }\n\n return null;\n}\n";
723
+ //#endregion
724
+ //#region src/client/jxa/write.d.ts
725
+ /**
726
+ * One script, one verb.
727
+ *
728
+ * `send` is the only mutating command in the Messages dictionary that this
729
+ * server exposes — see `core.ts` for the full list and for why `login`/`logout`
730
+ * and the file form of `send` are left out.
731
+ *
732
+ * ## What this script deliberately does NOT do
733
+ *
734
+ * It does not report success from its own read-back, because there is nothing to
735
+ * read back: `send` returns no value, and every read this app offers fails. A
736
+ * script that answered `{ok: true}` and stopped would be claiming delivery on
737
+ * the strength of a command that did not throw — the exact shape of "plausible,
738
+ * wrong and silent" this repo keeps designing against.
739
+ *
740
+ * So the script's answer is deliberately narrow: **the send command was accepted
741
+ * by Messages, and here is how the target was addressed.** Whether a row landed
742
+ * is a question for the file lane, and `client/messages.ts` asks it immediately
743
+ * afterwards by polling chat.db for the outgoing row. That split is the answer
744
+ * to the open question `docs/messages.md` left — "whether a send should
745
+ * re-resolve by scanning the store for a recent row on the target chat" — and it
746
+ * is what makes a send reportable at all on a surface with no id bridge.
747
+ */
748
+ declare const SEND_MESSAGE = "\nObjC.import(\"AppKit\");\n\nfunction ok(data) { return JSON.stringify({ ok: true, data: data }); }\nfunction err(code, message, extra) {\n var e = { code: code, message: String(message) };\n if (extra) e.detail = extra;\n return JSON.stringify({ ok: false, error: e });\n}\n\n/** Read one property defensively — every read on this surface is allowed to fail. */\nfunction prop(fn, fallback) {\n try {\n var v = fn();\n return v === undefined ? fallback : v;\n } catch (e) {\n return fallback;\n }\n}\n\nfunction isMessagesRunning() {\n var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(\"com.apple.MobileSMS\");\n return apps.count > 0;\n}\n\n/** The dictionary's service type enumeration: SMS, iMessage, RCS. */\nfunction serviceEnum(name) {\n var s = String(name || \"\").toLowerCase();\n if (s === \"sms\") return \"SMS\";\n if (s === \"rcs\") return \"RCS\";\n return \"iMessage\";\n}\n\n/**\n * Find something `send` will accept as its `to`.\n *\n * Ordered cheapest and most-likely-to-work first. Every rung is wrapped, every\n * failure is recorded, and the caller is told which one answered — on a surface\n * whose whole read half is known broken, \"it worked\" without \"how\" is not a\n * result anyone can act on later.\n */\nfunction resolveTarget(M, p, tried) {\n var i;\n\n // 1. The chat guid the file lane read out of chat.db. No enumeration.\n if (p.chatGuid) {\n try {\n var chat = M.chats.byId(p.chatGuid);\n chat.id();\n return { target: chat, strategy: \"chat-guid\", kind: \"chat\" };\n } catch (e) {\n tried.push(\"chat-guid: \" + (e.message || e));\n }\n }\n\n if (!p.handle) return null;\n\n // 2. The guid Messages composes for a one-to-one chat, spelled the way the\n // store spells it: \"iMessage;-;+15551234567\". Constructed rather than read,\n // so it is below the real one and above everything that enumerates.\n var services = p.service ? [serviceEnum(p.service)] : [\"iMessage\", \"SMS\", \"RCS\"];\n for (i = 0; i < services.length; i++) {\n var guess = services[i] + \";-;\" + p.handle;\n try {\n var guessed = M.chats.byId(guess);\n guessed.id();\n return { target: guessed, strategy: \"chat-guid-guess\", kind: \"chat\", guid: guess };\n } catch (e2) {\n tried.push(\"chat-guid-guess(\" + guess + \"): \" + (e2.message || e2));\n }\n }\n\n // 3. A participant reached through its account. This enumerates, so it is\n // expected to fail with \"Application isn't running\" — kept because it is\n // the form every AppleScript example on the internet uses, and because if\n // launching the app does wake the scripting interface, this is what works.\n for (i = 0; i < services.length; i++) {\n try {\n var accounts = M.accounts.whose({ serviceType: services[i] })();\n for (var j = 0; j < accounts.length; j++) {\n try {\n var buddy = accounts[j].participants.whose({ handle: p.handle })()[0];\n if (buddy) {\n buddy.id();\n return { target: buddy, strategy: \"account-participant\", kind: \"participant\" };\n }\n } catch (e4) {\n tried.push(\"account-participant(\" + services[i] + \"): \" + (e4.message || e4));\n }\n }\n } catch (e3) {\n tried.push(\"accounts(\" + services[i] + \"): \" + (e3.message || e3));\n }\n }\n\n // 4. The flat participant list, last because it is the widest enumeration.\n try {\n var flat = M.participants.whose({ handle: p.handle })()[0];\n if (flat) {\n flat.id();\n return { target: flat, strategy: \"participant\", kind: \"participant\" };\n }\n tried.push(\"participant: no participant with that handle\");\n } catch (e5) {\n tried.push(\"participant: \" + (e5.message || e5));\n }\n\n return null;\n}\n\nfunction run(argv) {\n var p = JSON.parse(argv[0]);\n var M = Application(\"Messages\");\n\n var wasRunning = isMessagesRunning();\n if (!wasRunning && !p.allowLaunch) {\n return err(\"APP_NOT_RUNNING\", \"Messages is not running.\");\n }\n\n var tried = [];\n var resolved = resolveTarget(M, p, tried);\n if (!resolved) {\n return err(\n \"SEND_TARGET_NOT_FOUND\",\n \"Messages would not resolve a chat or participant for that recipient.\",\n tried\n );\n }\n\n try {\n M.send(p.text, { to: resolved.target });\n } catch (e) {\n return err(\"SEND_FAILED\", e.message || e, tried);\n }\n\n return ok({\n strategy: resolved.strategy,\n targetKind: resolved.kind,\n // Best effort, and allowed to be null: reading the id back is itself a read.\n targetId: prop(function () { return String(resolved.target.id()); }, null),\n launched: !wasRunning,\n attempts: tried\n });\n}\n";
749
+ //#endregion
750
+ //#region src/server.d.ts
751
+ declare const SERVER_NAME: string;
752
+ declare const SERVER_VERSION: string;
753
+ type CreateServerOptions = {
754
+ config: Config;
755
+ logger?: Logger;
756
+ /** Injected by tests so nothing spawns a process or touches real Messages. */
757
+ osascript?: OsascriptRunner;
758
+ /** Injected by tests so discovery never reaches the developer's real home. */
759
+ home?: string;
760
+ /** Injected by tests, so no test reaches the developer's real address book. */
761
+ contacts?: ConstructorParameters<typeof AppleMessagesClient>[0]["contacts"];
762
+ };
763
+ type CreatedServer = {
764
+ server: McpServer;
765
+ client: AppleMessagesClient;
766
+ };
767
+ /**
768
+ * Build the server. Side-effect free: it opens no database and reads no file,
769
+ * so a test can construct it freely and every external dependency arrives
770
+ * through an option.
771
+ */
772
+ declare const createServer: (opts: CreateServerOptions) => CreatedServer;
773
+ //#endregion
774
+ //#region src/tools/index.d.ts
775
+ type ToolContext = {
776
+ /**
777
+ * Gates the two tools that change something outside this process.
778
+ *
779
+ * `send_message` is the only one that touches Messages.app, and it is also
780
+ * every Apple Event this server can send, because sending is the only thing
781
+ * Apple Events can do here — there is no read lane to fall back to and never
782
+ * can be. So with writes off this server remains inert with respect to
783
+ * Messages.app: it opens a file and nothing else.
784
+ *
785
+ * `save_attachment` is gated for a different reason. It sends no Apple Event
786
+ * and changes nothing in Messages; what it does is create a file on the
787
+ * user's disk, which Mail and Notes also treat as a write. The claim above
788
+ * survives it intact.
789
+ */
790
+ allowWrites: boolean;
791
+ };
792
+ /**
793
+ * Register the Apple Messages tools.
794
+ *
795
+ * Five reads, always. Two writes, only when `allowWrites` is on — and on this
796
+ * surface the flag carries a permission claim as well as a safety one: with it
797
+ * off no Apple Event is ever sent, so no Automation grant is ever requested.
798
+ * What is needed either way is Full Disk Access, absolutely — see `diagnostics`.
799
+ *
800
+ * The registered set does NOT vary with whether the store is readable. That is a
801
+ * runtime condition, and MCP clients cache the tool list.
802
+ */
803
+ declare const registerTools: (server: McpServer, client: AppleMessagesClient, ctx: ToolContext) => void;
804
+ //#endregion
805
+ export { ATTACHMENTS_RELATIVE, AppleMessagesClient, AppleMessagesError, BUILD_INFO, type BuildInfo, CHAT_REF_VERSION, CORE_DATA_EPOCH_OFFSET, ChatNotFoundError, type ChatRow, type Config, type Correspondent, type CreateClientOptions, type CreateServerOptions, type DecodedBody, IndexUnavailableError, InvalidMessageRefError, type LocateResult, MESSAGES_BUNDLE_ID, MESSAGES_SURFACE, MESSAGE_REF_VERSION, MessageNotFoundError, type MessageRow, MessagesStore, MessagesUnavailableError, PRELUDE, type RangeQuery, type RenderedChat, type RenderedMessage, SEND_MESSAGE, SERVER_NAME, SERVER_VERSION, STORE_RELATIVE, SchemaDriftError, SendFailedError, type SendResult, SendTargetNotFoundError, type StoreCapabilities, type ToolContext, appleSecondsSql, createServer, decodeAttributedBody, decodeChatRef, decodeMessageRef, defaultStorePath, encodeChatRef, encodeMessageRef, fromAppleSeconds, introspect, loadConfig, locateStore, openStore, outline, reactionLabel, registerTools, renderInstant, toAppleSeconds };
806
+ //# sourceMappingURL=index.d.ts.map