@mgcrea/mcp-apple-messages 1.31.0 → 1.34.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.
@@ -1,10 +1,10 @@
1
- import { AppleAutomationError, AppleAutomationError as AppleMessagesError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, IndexUnavailableError as IndexUnavailableError$1, PreconditionError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, buildBaseDiagnostics, columnsOf, confirmArg, createOsascriptRunner, describeAggregation, describeStore, escapeLike, extractCode, fail, fingerprintSchema, groupByArg, limitArg, ok, openReadOnly, parseBool, parseBound, parseConfig, parseIntOpt, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, resolveLimit, trimmed, withBusyRetry, withLazyTools, wrap, wrapResult, writeConfinedFile } from "@mgcrea/mcp-apple-core";
1
+ import { AppleAutomationError, AppleAutomationError as AppleMessagesError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, IndexUnavailableError as IndexUnavailableError$1, PreconditionError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, buildBaseDiagnostics, columnsOf, confirmArg, createOsascriptRunner, createWatches, describeAggregation, describeStore, escapeLike, extractCode, fail, fileIdentity, fileTrigger, fingerprintSchema, groupByArg, isoMs, limitArg, normalizeFilter, occurrenceTimestamp, ok, openReadOnly, parseBool, parseBound, parseConfig, parseIntOpt, parseSenderArgs, promptArg, readPackageIdentity, registerEvents, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, resolveLimit, sameFile, trimmed, withBusyRetry, withLazyTools, wrap, wrapResult, writeConfinedFile } from "@mgcrea/mcp-apple-core";
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { basename, isAbsolute, join, resolve, sep } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
5
5
  import { AppleContactsClient, emailKey, handleKind, loadConfig, suffixKey } from "@mgcrea/mcp-apple-contacts";
6
6
  import { z } from "zod";
7
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { McpServer } from "@modelcontextprotocol/server";
8
8
  //#region src/build-info.ts
9
9
  const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
10
10
  name: "@mgcrea/mcp-apple-messages",
@@ -13,8 +13,8 @@ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
13
13
  const BUILD_INFO = {
14
14
  name: pkg.name,
15
15
  version: pkg.version,
16
- gitCommit: "cf7ad84",
17
- gitCommitDate: "2026-10-25T15:13:25+01:00"
16
+ gitCommit: "735f35d",
17
+ gitCommitDate: "2026-10-29T10:51:42+01:00"
18
18
  };
19
19
  //#endregion
20
20
  //#region src/client/dates.ts
@@ -491,13 +491,12 @@ const locateStore = (opts = {}) => {
491
491
  *
492
492
  * ## Why a GUID rather than a rowid
493
493
  *
494
- * Both exist, and the rowid is faster to look up. The GUID wins anyway because
495
- * **rowids are reused.** SQLite hands a deleted row's id to the next insert
496
- * unless the table is `AUTOINCREMENT`, and Messages deletes constantly — every
497
- * "delete this conversation" frees a block of them. A ref handed to a model in
498
- * one turn and used two turns later would then resolve to a DIFFERENT message,
499
- * with no error anywhere. That is the failure this project keeps designing
500
- * against: plausible, wrong, and silent.
494
+ * Both exist, and the rowid is faster to look up. The GUID wins anyway, and not
495
+ * because rowids are reused: `message` is `AUTOINCREMENT`, so a deleted row's id
496
+ * is never handed to a later insert (the watch cursor relies on exactly that,
497
+ * and `watch.test.ts` pins it). A rowid is a position in this one database file.
498
+ * It means nothing on another device or after a sync rebuilds the store, whereas
499
+ * the GUID is the message's own identity and travels with it.
501
500
  *
502
501
  * The GUID is also what Apple itself joins on — `associated_message_guid` for
503
502
  * reactions, `thread_originator_guid` for replies — so it is the identifier the
@@ -974,12 +973,17 @@ var MessagesStore = class {
974
973
  source: "none"
975
974
  };
976
975
  }
977
- #messageColumns() {
976
+ /**
977
+ * `bodies: false` projects NULL for both text columns, for a read that never
978
+ * shows a body: `#toRow` would otherwise decode `attributedBody` on every row
979
+ * only for the caller to drop it.
980
+ */
981
+ #messageColumns({ bodies = true } = {}) {
978
982
  const m = this.caps.messageColumns;
979
983
  return [
980
984
  `m."guid" AS guid`,
981
- `m."text" AS text`,
982
- m.has("attributedBody") ? `m."attributedBody" AS attributedBody` : `NULL AS attributedBody`,
985
+ bodies ? `m."text" AS text` : `NULL AS text`,
986
+ bodies && m.has("attributedBody") ? `m."attributedBody" AS attributedBody` : `NULL AS attributedBody`,
983
987
  this.#col(m, "m", "subject"),
984
988
  this.#col(m, "m", "service"),
985
989
  this.#col(m, "m", "is_from_me", "isFromMe"),
@@ -1068,6 +1072,40 @@ var MessagesStore = class {
1068
1072
  LIMIT ${Math.max(1, Math.trunc(q.limit))}`;
1069
1073
  return this.db.prepare(sql).all(...params).map((r) => this.#toRow(r));
1070
1074
  }
1075
+ /** Highest message ROWID now, or 0 for an empty store. The watch cursor's head. */
1076
+ headRowid() {
1077
+ return this.db.prepare(`SELECT MAX("ROWID") AS head FROM "message"`).get().head ?? 0;
1078
+ }
1079
+ /**
1080
+ * Messages with ROWID above `after`, ascending, at most `limit`.
1081
+ *
1082
+ * Nothing is filtered out, reactions and my own messages included: a watch
1083
+ * cursor advances over every ROWID and the caller decides what is excluded.
1084
+ * `message` is AUTOINCREMENT, so a ROWID is never handed out twice and the
1085
+ * cursor can only move forward.
1086
+ *
1087
+ * Exactly one row per ROWID. `#joins` yields a row per chat for a message in
1088
+ * several, which would give one cursor two rows and spend two slots of a read's
1089
+ * limit on one message, so the chat here is picked: the lowest `chat_id`.
1090
+ *
1091
+ * No text: a watch carries metadata only, so `text` is always null here and
1092
+ * the scan never pays for decoding a body.
1093
+ */
1094
+ arrivalsAfter(after, limit) {
1095
+ const sql = `
1096
+ SELECT m."ROWID" AS rowid, ${this.#messageColumns({ bodies: false })}
1097
+ FROM "message" m
1098
+ LEFT JOIN "handle" h ON h."ROWID" = m."handle_id"
1099
+ LEFT JOIN "chat" c ON c."ROWID" =
1100
+ (SELECT MIN(cmj."chat_id") FROM "chat_message_join" cmj WHERE cmj."message_id" = m."ROWID")
1101
+ WHERE m."ROWID" > ?
1102
+ ORDER BY m."ROWID" ASC
1103
+ LIMIT ?`;
1104
+ return this.db.prepare(sql).all(after, Math.max(1, Math.trunc(limit))).map((r) => ({
1105
+ ...this.#toRow(r),
1106
+ rowid: Number(r.rowid)
1107
+ }));
1108
+ }
1071
1109
  /**
1072
1110
  * Text search, in two passes — and the second one is the point.
1073
1111
  *
@@ -1864,14 +1902,14 @@ var AppleMessagesClient = class {
1864
1902
  * answered zero while `send_message` to that same string worked. One
1865
1903
  * surface, two ideas of what a handle is.
1866
1904
  */
1867
- #handlesLike(handle) {
1905
+ #handlesLike(handle, pool) {
1868
1906
  const store = this.store();
1869
1907
  if (!store) return [];
1870
1908
  const wanted = handle.trim();
1871
1909
  const kind = handleKind(wanted);
1872
1910
  const wantedSuffix = kind === "phone" ? suffixKey(wanted) : null;
1873
1911
  const wantedEmail = kind === "email" ? emailKey(wanted) : null;
1874
- return store.handles().filter((h) => {
1912
+ return (pool ?? store.handles()).filter((h) => {
1875
1913
  if (h === wanted) return true;
1876
1914
  if (wantedEmail) return emailKey(h) === wantedEmail;
1877
1915
  if (wantedSuffix) return suffixKey(h) === wantedSuffix;
@@ -1879,6 +1917,25 @@ var AppleMessagesClient = class {
1879
1917
  });
1880
1918
  }
1881
1919
  /**
1920
+ * The stored spellings that name the same correspondent as `typed`. Candidates,
1921
+ * not answers: a locally-spelled number reaches `+33…` here, so a caller that
1922
+ * needs an exact spelling still has to compare. `[]` when the store is closed.
1923
+ * `pool` is the store's handles when the caller already read them.
1924
+ */
1925
+ storeHandlesFor(typed, pool) {
1926
+ return this.#handlesLike(typed, pool);
1927
+ }
1928
+ /**
1929
+ * The open store and the file it came from, for a watch. Throws the same
1930
+ * reason every other read throws when the store is off or unreadable.
1931
+ */
1932
+ watchStore() {
1933
+ return {
1934
+ store: this.#require(),
1935
+ path: this.located().storePath
1936
+ };
1937
+ }
1938
+ /**
1882
1939
  * Find the row Messages wrote, or say plainly that it was not found.
1883
1940
  *
1884
1941
  * `docs/messages.md` left this as an open question — "whether a send should
@@ -2131,7 +2188,27 @@ const ConfigSchema = BaseConfigSchema.extend({
2131
2188
  * reassuring one. `attachmentDir` is a real boundary because it constrains
2132
2189
  * where this server WRITES, which nothing else on this surface can do.
2133
2190
  */
2134
- allowFileSend: z.boolean().default(false)
2191
+ allowFileSend: z.boolean().default(false),
2192
+ /**
2193
+ * Lets a client register a watch on handles or email domains and be told when
2194
+ * a matching message arrives. Off by default, and independent of `allowWrites`
2195
+ * and `exposePrompts`.
2196
+ *
2197
+ * Not a write gate: a watch only reads chat.db. It is a gate because it is the
2198
+ * one thing here that runs unasked, a file watcher in a process that otherwise
2199
+ * has none, and because the handle filter it takes decides whose messages may
2200
+ * reach a session without the model asking for it. Off, neither watch tool is
2201
+ * registered and the server does not declare `resources.subscribe`.
2202
+ */
2203
+ allowWatches: z.boolean().default(false),
2204
+ /**
2205
+ * Serves the draft MCP Events extension (`messages.received`) to clients on the 2026
2206
+ * protocol. Off by default, and independent of `allowWatches`, `allowWrites` and
2207
+ * `exposePrompts`. Like a watch it only reads chat.db, and is gated for the same
2208
+ * reason: it runs unasked, and its handle filter decides whose messages may reach a
2209
+ * session. Off, no `events/*` method exists and nothing is declared.
2210
+ */
2211
+ allowEvents: z.boolean().default(false)
2135
2212
  }).strict();
2136
2213
  const loadConfig$1 = (env = process.env) => parseConfig(ConfigSchema, {
2137
2214
  allowWrites: parseBool(env.APPLE_MESSAGES_ALLOW_WRITES),
@@ -2145,6 +2222,8 @@ const loadConfig$1 = (env = process.env) => parseConfig(ConfigSchema, {
2145
2222
  sendReconcileMs: parseIntOpt(env.APPLE_MESSAGES_SEND_RECONCILE_MS),
2146
2223
  allowCodes: parseBool(env.APPLE_MESSAGES_ALLOW_CODES),
2147
2224
  allowFileSend: parseBool(env.APPLE_MESSAGES_ALLOW_FILE_SEND),
2225
+ allowWatches: parseBool(env.APPLE_MESSAGES_ALLOW_WATCHES),
2226
+ allowEvents: parseBool(env.APPLE_MESSAGES_ALLOW_EVENTS),
2148
2227
  osascriptPath: trimmed(env.APPLE_MESSAGES_OSASCRIPT_PATH),
2149
2228
  osascriptTimeoutMs: parseIntOpt(env.APPLE_MESSAGES_OSASCRIPT_TIMEOUT_MS),
2150
2229
  maxResults: parseIntOpt(env.APPLE_MESSAGES_MAX_RESULTS)
@@ -2212,6 +2291,45 @@ there, that is this, not silence.
2212
2291
  conversations and reported on the message they target. A "liked" is not a reply
2213
2292
  and should not be summarised as one.
2214
2293
 
2294
+ ## Watches
2295
+
2296
+ \`apple_messages_watch\` and \`apple_messages_unwatch\` exist only when the watch
2297
+ gate (\`APPLE_MESSAGES_ALLOW_WATCHES\`) is on. If you cannot see them, watches
2298
+ are off.
2299
+
2300
+ - **A filter is required.** Give \`from\` or \`domains\` (email handles only; a
2301
+ phone number never matches a domain). There is no watch on all messages.
2302
+ - **Spell phone numbers in international form**: \`+33612345678\`, with no trunk
2303
+ \`0\` and no \`(0)\`. Spaces and punctuation are ignored, but a local spelling
2304
+ such as \`06 12 34 56 78\` never matches. An entry the store does not hold is
2305
+ kept without spaces or punctuation, lowercased, and listed in \`unresolved\`.
2306
+ That is NOT proof the person has not written: it fires only if the store later
2307
+ holds exactly that spelling. When the store holds near misses they come back
2308
+ as \`suggestions\` (\`typed\`, \`candidates\`); retry with the right spelling.
2309
+ - **Check the watch yourself.** \`apple_messages_watch\` returns a handle and a
2310
+ \`uri\`. Only the host can subscribe to that uri, there is no tool for it, and
2311
+ many hosts never do: Claude Code does not, in either protocol (checked on
2312
+ 2.1.311). So do not promise the user a push. The watch still works: read the
2313
+ uri whenever you want to know what came in.
2314
+ - **\`after\` is yours to keep.** A read returns what arrived since the watch
2315
+ began, or since the \`after\` you pass, oldest first. The \`cursor\` it returns
2316
+ is the last row scanned; pass it back as \`after\` next time. The server does
2317
+ not remember what you have seen. When \`more\` is true, read again from
2318
+ \`cursor\`.
2319
+ - **Reads carry no text.** Each message is \`id\`, \`handle\`, \`chat\` and \`date\`.
2320
+ Use \`apple_messages_get_message\` with that \`id\` for the text.
2321
+ - **Your own messages, tapbacks and group events never fire a watch.** A group
2322
+ event is a rename, or someone joining or leaving.
2323
+ - **Limits.** At most 10 watches per connection and 50 entries across \`from\`
2324
+ and \`domains\` in one watch. A read returns at most 50 matches and scans at
2325
+ most 1,000 rows; \`more\` says when either stopped it.
2326
+ - **Messages need not be running.** The store is written by the system, not by
2327
+ the app, so a watch works with Messages closed.
2328
+ - Watches end with the connection: after a reconnect, call
2329
+ \`apple_messages_watch\` again. Reading a watch that does not exist is a
2330
+ not-found error, which is also what a reconnect looks like. A uri with a query
2331
+ other than \`after\` is refused as a bad query.
2332
+
2215
2333
  ## Sending
2216
2334
 
2217
2335
  \`apple_messages_send_message\` exists only when writes are enabled, and it is the
@@ -2366,11 +2484,94 @@ const buildDiagnostics = async (client) => {
2366
2484
  const registerDiagnosticsTools = (server, client) => {
2367
2485
  server.registerTool("apple_messages_diagnostics", {
2368
2486
  description: "Report whether the Messages store could be opened, how much is in it, whether names are being resolved, and what this server cannot do. Start here when a read returns nothing.",
2369
- inputSchema: {},
2487
+ inputSchema: z.object({}),
2370
2488
  annotations: { readOnlyHint: true }
2371
2489
  }, async () => wrap(() => buildDiagnostics(client)));
2372
2490
  };
2373
2491
  //#endregion
2492
+ //#region src/tools/events.ts
2493
+ /**
2494
+ * Messages' `messages.received` event type for the draft MCP Events extension.
2495
+ *
2496
+ * It is an adapter over a `MessagesWatchService`, the same store handle, handle
2497
+ * resolution and trigger a watch uses. The service handed over must be its own,
2498
+ * never one bound to a watch registry: events and watches then keep separate
2499
+ * handles on chat.db and both are closed with the connection.
2500
+ */
2501
+ const Args = z.object({
2502
+ from: z.array(z.string().min(1)).optional(),
2503
+ domains: z.array(z.string().min(1)).optional()
2504
+ });
2505
+ const messagesEventAdapter = (service) => ({
2506
+ descriptor: {
2507
+ name: "messages.received",
2508
+ description: "New messages from chosen handles. Give `from` (email addresses, or phone numbers in international form such as +33612345678) and/or `domains` (email handles only; matches the domain and its subdomains); a sender is required. Metadata only, so fetch the text with apple_messages_get_message and the `id`. Your own messages and tapbacks never match. Needs Full Disk Access; Messages does not have to be running.",
2509
+ delivery: ["poll", "push"],
2510
+ inputSchema: {
2511
+ type: "object",
2512
+ properties: {
2513
+ from: {
2514
+ type: "array",
2515
+ items: {
2516
+ type: "string",
2517
+ minLength: 1
2518
+ },
2519
+ description: "Email addresses, or phone numbers in international form (+33612345678: no trunk 0, no (0)); spaces and punctuation are ignored, but a local spelling like 06 12 34 56 78 never matches. An entry the store does not hold is kept without spaces or punctuation, lowercased: it fires only if the store later holds exactly that spelling."
2520
+ },
2521
+ domains: {
2522
+ type: "array",
2523
+ items: {
2524
+ type: "string",
2525
+ minLength: 1
2526
+ },
2527
+ description: "Email domains, e.g. acme.com. Matches only email handles, never a phone number."
2528
+ }
2529
+ },
2530
+ anyOf: [{ required: ["from"] }, { required: ["domains"] }]
2531
+ },
2532
+ payloadSchema: {
2533
+ type: "object",
2534
+ properties: {
2535
+ id: { type: "string" },
2536
+ chat: { type: ["string", "null"] },
2537
+ handle: { type: ["string", "null"] },
2538
+ date: { type: ["string", "null"] }
2539
+ },
2540
+ required: [
2541
+ "id",
2542
+ "chat",
2543
+ "handle",
2544
+ "date"
2545
+ ]
2546
+ }
2547
+ },
2548
+ parseArguments: (args) => parseSenderArgs("messages.received", Args, args, {
2549
+ open: () => service.ensure(),
2550
+ resolve: async ({ from, domains }) => {
2551
+ const resolved = service.resolveFrom(from ?? []).from;
2552
+ return {
2553
+ ...resolved.length > 0 ? { from: resolved } : {},
2554
+ ...domains ? { domains } : {}
2555
+ };
2556
+ }
2557
+ }),
2558
+ occurrence: (row) => ({
2559
+ eventId: row.key,
2560
+ timestamp: occurrenceTimestamp(row.date),
2561
+ data: {
2562
+ id: row.id,
2563
+ chat: row.chat,
2564
+ handle: row.handle,
2565
+ date: row.date
2566
+ }
2567
+ }),
2568
+ timestampMs: (row) => isoMs(row.date),
2569
+ source: service.source,
2570
+ ensure: () => service.refresh(),
2571
+ identity: () => service.identity(),
2572
+ startTrigger: (onChange) => service.startTrigger(onChange)
2573
+ });
2574
+ //#endregion
2374
2575
  //#region src/tools/actions.ts
2375
2576
  /**
2376
2577
  * The payload half of the description, which changes with `allowFileSend` — the
@@ -2381,7 +2582,7 @@ const PAYLOAD_HELP = (allowFileSend) => "Send exactly ONE thing per call: text,
2381
2582
  const registerActionTools = (server, client, ctx) => {
2382
2583
  server.registerTool("apple_messages_send_message", {
2383
2584
  description: "Send a message to an existing conversation or to a phone number / email address. This sends a REAL message from the user's own iMessage/SMS account, immediately and irreversibly. There is no unsend, no draft and no preview: the recipient's phone buzzes as soon as this returns. Messages may be launched to do it, which the user will see. Confirm the exact wording and the exact recipient with the user before calling this. Sending a file is the same act and less recoverable: once it leaves this Mac the recipient has a copy, so confirm WHICH file with the user, by name, before calling this with an attachment. Prefer chatRef from apple_messages_list_chats over a raw handle: Messages refuses to enumerate participants for a script, so an existing conversation is the only target this server can address reliably — a handle with no conversation on this Mac will usually fail rather than start a new thread. The result reports which targeting strategy worked, and reconciles against the message store to hand back a real message ref: reconciliation `matched` means the sent row was found and `message` is it, `pending` means Messages accepted the send but has not written the row yet (normal on a slow network — read the chat back, do NOT send again), and `unavailable` means there was no existing chat to look in. " + PAYLOAD_HELP(ctx.allowFileSend),
2384
- inputSchema: {
2585
+ inputSchema: z.object({
2385
2586
  chatRef: z.string().optional().describe("An opaque chat ref from apple_messages_list_chats (\"mc1:<guid>\"). The reliable way to target a send. Do not construct one by hand."),
2386
2587
  to: z.string().optional().describe("A phone number or email address, when there is no chat ref. Matched against the store's own handles by last-9-digits, so local and international spellings of the same number both work."),
2387
2588
  text: z.string().min(1).optional().describe("Exactly what to send, verbatim. Give this OR an attachment, never both in one call."),
@@ -2393,7 +2594,7 @@ const registerActionTools = (server, client, ctx) => {
2393
2594
  "rcs"
2394
2595
  ]).optional().describe("Which service to prefer when addressing a handle with no existing chat. Ignored when chatRef is given, since the chat already knows its service."),
2395
2596
  confirm: confirmArg
2396
- },
2597
+ }),
2397
2598
  annotations: {
2398
2599
  readOnlyHint: false,
2399
2600
  destructiveHint: true,
@@ -2442,11 +2643,11 @@ const registerActionTools = (server, client, ctx) => {
2442
2643
  const registerAttachmentTools = (server, client) => {
2443
2644
  server.registerTool("apple_messages_save_attachment", {
2444
2645
  description: "Save one attachment from a conversation to disk — a photo, a video, a PDF, a voice memo. Take the `id` from an attachment on apple_messages_get_message. Needs Full Disk Access, like every read here. It can only write into APPLE_MESSAGES_ATTACHMENT_DIR (default ~/Downloads) and will not overwrite an existing file unless you ask it to. Write-gated because it puts a file on the user's disk, even though it changes nothing in Messages. An attachment iCloud has offloaded has no bytes on this Mac and is refused with that reason — open the conversation in Messages to pull it down first.",
2445
- inputSchema: {
2646
+ inputSchema: z.object({
2446
2647
  attachmentId: z.string().min(1).describe("The `id` of an attachment from apple_messages_get_message. Opaque; do not construct one, and do not pass the file path shown next to it."),
2447
2648
  directory: z.string().optional().describe("Override the target directory. Must still resolve inside the configured one."),
2448
2649
  overwrite: z.boolean().optional().describe("Replace an existing file. Default false.")
2449
- },
2650
+ }),
2450
2651
  annotations: {
2451
2652
  readOnlyHint: false,
2452
2653
  destructiveHint: false,
@@ -2468,7 +2669,7 @@ const registerAttachmentTools = (server, client) => {
2468
2669
  const registerChatTools = (server, client) => {
2469
2670
  server.registerTool("apple_messages_list_chats", {
2470
2671
  description: "List conversations, most recently active first, with their participants and message counts. Names come from Contacts where they resolve; where they do not, the raw phone number or email is shown and `resolution` says why. Use the returned ref to read one conversation with apple_messages_list_messages.",
2471
- inputSchema: { limit: limitArg },
2672
+ inputSchema: z.object({ limit: limitArg }),
2472
2673
  annotations: { readOnlyHint: true }
2473
2674
  }, async ({ limit }) => wrap(async () => client.listChats(limit)));
2474
2675
  };
@@ -2503,11 +2704,11 @@ CHECK \`ageSeconds\` BEFORE USING A CODE. An expired code is still in the store
2503
2704
  A \`matched\` value ending in \`-ambiguous\` means the message held more than one plausible code and this tool did not guess — confirm which one is wanted.
2504
2705
 
2505
2706
  Returning nothing is the normal result when no code has arrived. It does NOT mean the code was missed, and it is not a reason to re-run with a longer window: if a code was sent it is in the store within seconds. Prefer asking the user to trigger a new one.`,
2506
- inputSchema: {
2707
+ inputSchema: z.object({
2507
2708
  service: z.string().optional().describe("Narrow to one issuer by matching the sender or the message text — \"Google\", \"bank\", \"Github\". Case-insensitive substring. Omit to see every recent code."),
2508
2709
  withinMinutes: z.number().int().min(1).max(MAX_WINDOW_MINUTES).optional().describe(`How far back to look, in minutes. Default ${DEFAULT_WINDOW_MINUTES}, max ${MAX_WINDOW_MINUTES}. Keep it short: a code older than a few minutes has usually expired, and a wide window just returns history.`),
2509
2710
  limit: limitArg
2510
- },
2711
+ }),
2511
2712
  annotations: {
2512
2713
  readOnlyHint: true,
2513
2714
  idempotentHint: false
@@ -2571,7 +2772,7 @@ Returning nothing is the normal result when no code has arrived. It does NOT mea
2571
2772
  const registerCountTools = (server, client) => {
2572
2773
  server.registerTool("apple_messages_count_messages", {
2573
2774
  description: "Count messages without reading them. Answers 'how many', 'how many did I send', 'who do I message most' and 'which month was busiest' as arithmetic the store does, rather than a page of conversations you tally by hand. Every result splits `sent` and `received`.\n\n`groupBy` counts per day, month, chat, handle or direction, over EVERY match rather than the first `limit` of them — `limit` caps how many groups come back. Grouping by handle is the one to reach for when a person has several conversations, since apple_messages_list_chats already gives you per-CHAT totals.\n\nCounts metadata only: there is no text filter here, because message bodies from 2026 onward live in an archived blob that SQL cannot see, and a count that quietly skipped them would look exactly like a correct one. Use apple_messages_search_messages for anything about what was said. Tapbacks are excluded unless you ask for them.",
2574
- inputSchema: {
2775
+ inputSchema: z.object({
2575
2776
  chatRef: chatRefArg,
2576
2777
  handle: z.string().optional().describe("Only messages with this correspondent — a phone number or email. Spellings of the same number match each other, so \"06 12 34 56 78\" and \"+33612345678\" are the same person, exactly as they are for send_message. A fragment that resolves to nobody falls back to a substring match. Spans every chat they appear in, which is the difference from chatRef."),
2577
2778
  direction: z.enum(["sent", "received"]).optional().describe("Only what you sent, or only what you received. Omit for both."),
@@ -2580,7 +2781,7 @@ const registerCountTools = (server, client) => {
2580
2781
  includeReactions: includeReactionsArg,
2581
2782
  groupBy: groupByArg(COUNT_GROUP_FIELDS, "Day and month buckets are LOCAL calendar dates."),
2582
2783
  limit: limitArg
2583
- },
2784
+ }),
2584
2785
  annotations: { readOnlyHint: true }
2585
2786
  }, async ({ chatRef, handle, direction, from, to, includeReactions, groupBy, limit }) => wrap(async () => {
2586
2787
  const window = client.window(bound(from, "from", "start"), bound(to, "to", "end"));
@@ -2621,13 +2822,13 @@ const closeRange = (from, to, days) => {
2621
2822
  const registerMessageTools = (server, client) => {
2622
2823
  server.registerTool("apple_messages_list_messages", {
2623
2824
  description: "Read messages, newest first — the whole store, or one conversation, or a date window. Tapbacks are excluded by default because they are rows in the same table and would otherwise read as messages nobody typed.\n\nEvery message carries `from.name` where Contacts resolves it and `from.handle` always. `from.resolution` is worth reading: `unknown` is COMMON AND NOT AN ERROR — measured on a real store, about one in six of even the busiest correspondents has no contact card.",
2624
- inputSchema: {
2825
+ inputSchema: z.object({
2625
2826
  chatRef: chatRefArg,
2626
2827
  from: fromArg,
2627
2828
  to: toArg,
2628
2829
  includeReactions: includeReactionsArg,
2629
2830
  limit: limitArg
2630
- },
2831
+ }),
2631
2832
  annotations: { readOnlyHint: true }
2632
2833
  }, async ({ chatRef, from, to, includeReactions, limit }) => wrap(async () => {
2633
2834
  const fromDate = bound(from, "from", "start");
@@ -2641,15 +2842,15 @@ const registerMessageTools = (server, client) => {
2641
2842
  }));
2642
2843
  server.registerTool("apple_messages_search_messages", {
2643
2844
  description: "Search message text across every conversation. Matches are substring and case-insensitive.\n\nThis searches ALL messages, including the roughly 3% whose text lives only in an archived blob rather than in a plain column — those are invisible to any SQL query and are decoded here. `textSource` on each result says which produced it.",
2644
- inputSchema: {
2845
+ inputSchema: z.object({
2645
2846
  query: z.string().min(1).describe("Text to look for."),
2646
2847
  limit: limitArg
2647
- },
2848
+ }),
2648
2849
  annotations: { readOnlyHint: true }
2649
2850
  }, async ({ query, limit }) => wrap(async () => client.searchMessages(query, limit)));
2650
2851
  server.registerTool("apple_messages_get_message", {
2651
2852
  description: "One message in full, with its tapbacks and attachments. Reactions are reported here rather than mixed into the conversation, which is the whole reason list_messages filters them out.",
2652
- inputSchema: { ref: messageRefArg },
2853
+ inputSchema: z.object({ ref: messageRefArg }),
2653
2854
  annotations: { readOnlyHint: true }
2654
2855
  }, async ({ ref }) => wrapResult(async () => {
2655
2856
  const message = client.getMessage(decodeMessageRef(ref));
@@ -2658,6 +2859,284 @@ const registerMessageTools = (server, client) => {
2658
2859
  }));
2659
2860
  };
2660
2861
  //#endregion
2862
+ //#region src/client/watch.ts
2863
+ var MessagesArrivalSource = class {
2864
+ #store;
2865
+ constructor(opts) {
2866
+ this.#store = opts.store;
2867
+ }
2868
+ head() {
2869
+ return this.#store.headRowid();
2870
+ }
2871
+ since(after, limit) {
2872
+ return this.#store.arrivalsAfter(after, limit).map((r) => {
2873
+ const excluded = r.isFromMe || r.reactionType !== null || r.itemType !== null && r.itemType !== 0;
2874
+ return {
2875
+ cursor: r.rowid,
2876
+ key: r.guid,
2877
+ sender: r.handle?.trim().toLowerCase() || null,
2878
+ ...excluded ? { excluded: true } : {},
2879
+ id: encodeMessageRef(r.guid),
2880
+ chat: r.chatGuid ? encodeChatRef(r.chatGuid) : null,
2881
+ handle: r.handle,
2882
+ date: renderInstant(r.sentAt)
2883
+ };
2884
+ });
2885
+ }
2886
+ };
2887
+ //#endregion
2888
+ //#region src/tools/watch.ts
2889
+ /**
2890
+ * Messages' side of incoming watches: the lazily opened source, the trigger, the
2891
+ * handle resolution and the two tools.
2892
+ *
2893
+ * ## Why the source is lazy
2894
+ *
2895
+ * `createWatches` needs an `ArrivalSource` when the server is built, but a server
2896
+ * is built far more often than it is used (`serveStdio` makes one per
2897
+ * `server/discover` probe and throws it away). So the source handed over is an
2898
+ * adapter that delegates to a `MessagesArrivalSource` built by `ensure()`, which
2899
+ * runs on the first `watch` call. A server that never sees one opens nothing.
2900
+ *
2901
+ * ## Messages need not be running
2902
+ *
2903
+ * `imagent` writes chat.db, not Messages.app, so there is no running check and no
2904
+ * lookup to refresh: the trigger just ticks the registry.
2905
+ *
2906
+ * ## The watch keeps its own handle, and reopens it when the file is replaced
2907
+ *
2908
+ * A connection opened on chat.db reads that inode for as long as it lives. A
2909
+ * store rebuilt as a new file and renamed over the old name leaves it reading
2910
+ * the deleted one: nothing new ever arrives there, and nothing errors. So the
2911
+ * service records the file's inode when it opens, compares on every trigger, and
2912
+ * reopens before the tick when it changed. The registry then sees the renumbered
2913
+ * head below its mark and resets without notifying, which is what a rebuild is.
2914
+ *
2915
+ * The handle is the service's own rather than the client's: the client closes
2916
+ * its store to reopen it after a send (`#reconcile`), which would close a shared
2917
+ * handle under a running watch, and reopening the client's would change what
2918
+ * every other tool reads in the middle of its call.
2919
+ *
2920
+ * ## Handles
2921
+ *
2922
+ * A watch's `from` holds exact store spellings, because the source stamps each row
2923
+ * with `handle.id` lowercased and core compares for equality. What the caller types
2924
+ * is resolved against the store's handles first (see `resolveFrom`).
2925
+ */
2926
+ const walReason = (noun) => `The Messages store was opened in immutable mode, which cannot see its WAL, so the newest messages are exactly what a ${noun} would miss. APPLE_MESSAGES_INDEX_MODE=immutable asks for that: set it to auto or ro, or unset it, then try again.`;
2927
+ /** How a handle compares: emails case-folded, phone numbers without their punctuation. */
2928
+ const handleKey = (value) => {
2929
+ const v = value.trim().toLowerCase();
2930
+ return v.includes("@") ? v : v.replaceAll(/[\s.\-()]/g, "");
2931
+ };
2932
+ var MessagesWatchService = class {
2933
+ #client;
2934
+ #logger;
2935
+ #noun;
2936
+ #registry;
2937
+ #inner;
2938
+ #dir;
2939
+ /** The service's own handle on chat.db, never the client's. */
2940
+ #store;
2941
+ #path;
2942
+ #identity = null;
2943
+ #reopenFailing = false;
2944
+ #init;
2945
+ /** What `createWatches` is given. Delegates to the source `ensure()` builds. */
2946
+ source = {
2947
+ head: () => this.#ready().head(),
2948
+ since: (after, limit) => this.#ready().since(after, limit)
2949
+ };
2950
+ constructor(opts) {
2951
+ this.#client = opts.client;
2952
+ this.#logger = opts.logger;
2953
+ this.#noun = opts.mode === "events" ? "subscription" : "watch";
2954
+ }
2955
+ /** How many watches this connection holds. */
2956
+ get count() {
2957
+ return this.#registry?.size ?? 0;
2958
+ }
2959
+ /** Called once, by `createServer`, after `createWatches` returns. */
2960
+ bind(registry) {
2961
+ this.#registry = registry;
2962
+ }
2963
+ #ready() {
2964
+ if (!this.#inner) throw new Error("The messages watch source was used before it was set up.");
2965
+ return this.#inner;
2966
+ }
2967
+ /**
2968
+ * Open the store and build the source, once. Concurrency-safe: two simultaneous
2969
+ * `watch` calls share the promise. A failure is not kept, so a later call runs
2970
+ * setup again.
2971
+ */
2972
+ ensure() {
2973
+ this.#init ??= Promise.resolve().then(() => this.#setUp()).catch((err) => {
2974
+ this.#init = void 0;
2975
+ throw err;
2976
+ });
2977
+ return this.#init;
2978
+ }
2979
+ /**
2980
+ * `ensure`, then reopen chat.db if it was replaced since. What an events poll
2981
+ * or tick runs before it reads: a poll has no trigger to notice a rebuild for
2982
+ * it, and without this would read the deleted file for as long as the
2983
+ * connection lives. `ensure` itself does not reopen, so watches are unchanged.
2984
+ */
2985
+ async refresh() {
2986
+ await this.ensure();
2987
+ this.#reopenIfReplaced();
2988
+ }
2989
+ #setUp() {
2990
+ const { store, path } = this.#client.watchStore();
2991
+ if (store.mode !== "ro") throw new PreconditionError(walReason(this.#noun));
2992
+ this.#open(path);
2993
+ this.#dir = dirname(path);
2994
+ }
2995
+ /** Open (or reopen) the service's own handle, and only then let go of the old one. */
2996
+ #open(path) {
2997
+ const identity = fileIdentity(path);
2998
+ const store = openStore(path, "ro", this.#logger);
2999
+ if (!store) throw new PreconditionError("The Messages store was not located.");
3000
+ const old = this.#store;
3001
+ this.#store = store;
3002
+ this.#path = path;
3003
+ this.#identity = identity;
3004
+ this.#inner = new MessagesArrivalSource({ store });
3005
+ old?.close();
3006
+ }
3007
+ /**
3008
+ * Reopen when chat.db is now a different file. A path that cannot be read is
3009
+ * a replacement in progress, so it is left for the next trigger. A reopen that
3010
+ * fails keeps the old handle and is logged once until one succeeds.
3011
+ */
3012
+ #reopenIfReplaced() {
3013
+ if (!this.#path) return;
3014
+ const now = fileIdentity(this.#path);
3015
+ if (now === null || this.#identity !== null && sameFile(now, this.#identity)) return;
3016
+ try {
3017
+ this.#open(this.#path);
3018
+ this.#reopenFailing = false;
3019
+ } catch (err) {
3020
+ if (!this.#reopenFailing) this.#logger?.warn?.("watch: chat.db was replaced and could not be reopened", err);
3021
+ this.#reopenFailing = true;
3022
+ }
3023
+ }
3024
+ /** What `createWatches` is given as `startTrigger`. Reads the directory now, not at construction. */
3025
+ startTrigger = (onChange) => {
3026
+ if (!this.#dir) throw new Error("A watch trigger was started before the store was located.");
3027
+ return fileTrigger({
3028
+ dir: this.#dir,
3029
+ names: ["chat.db", "chat.db-wal"],
3030
+ ...this.#logger ? { logger: this.#logger } : {}
3031
+ })(() => this.onTrigger(onChange));
3032
+ };
3033
+ /** The trigger's callback: reopen a replaced store, then tick. */
3034
+ onTrigger(tick) {
3035
+ this.#reopenIfReplaced();
3036
+ tick();
3037
+ }
3038
+ /** The file identity of the chat.db the service reads now, or null when it is unknown or unreadable. */
3039
+ identity() {
3040
+ return this.#identity;
3041
+ }
3042
+ /** Close the service's own handle. Called when the connection ends. */
3043
+ close() {
3044
+ this.#store?.close();
3045
+ this.#store = void 0;
3046
+ this.#inner = void 0;
3047
+ this.#path = void 0;
3048
+ this.#init = void 0;
3049
+ }
3050
+ remove(id) {
3051
+ return this.#registry?.remove(id) ?? false;
3052
+ }
3053
+ /**
3054
+ * Turn what the caller typed into exact store spellings.
3055
+ *
3056
+ * `storeHandlesFor` is a candidate list (a local "06 12 ..." reaches "+33 6 12 ..."),
3057
+ * so a typed value counts as resolved only when a candidate equals it after
3058
+ * normalisation. A short "+336" therefore resolves nothing, rather than every
3059
+ * French number. What does not resolve is kept, normalised and lowercased, and
3060
+ * reported (with `suggestions` when the store holds near misses).
3061
+ */
3062
+ resolveFrom(typed) {
3063
+ const from = [];
3064
+ const unresolved = [];
3065
+ const suggestions = [];
3066
+ const pool = this.#store?.handles();
3067
+ for (const entry of typed) {
3068
+ const key = handleKey(entry);
3069
+ if (key === "") continue;
3070
+ const candidates = this.#client.storeHandlesFor(entry, pool);
3071
+ const exact = candidates.filter((h) => handleKey(h) === key);
3072
+ if (exact.length > 0) from.push(...exact);
3073
+ else {
3074
+ from.push(key);
3075
+ unresolved.push(key);
3076
+ if (candidates.length > 0) suggestions.push({
3077
+ typed: entry,
3078
+ candidates
3079
+ });
3080
+ }
3081
+ }
3082
+ return {
3083
+ from: [...new Set(from)],
3084
+ unresolved: [...new Set(unresolved)],
3085
+ suggestions
3086
+ };
3087
+ }
3088
+ async add(input) {
3089
+ const registry = this.#registry;
3090
+ if (!registry) throw new Error("Messages watches are not bound to a registry.");
3091
+ normalizeFilter({
3092
+ ...input.from ? { from: input.from } : {},
3093
+ ...input.domains ? { domains: input.domains } : {}
3094
+ });
3095
+ await this.ensure();
3096
+ const { from, unresolved, suggestions } = this.resolveFrom(input.from ?? []);
3097
+ const filter = normalizeFilter({
3098
+ ...from.length ? { from } : {},
3099
+ ...input.domains ? { domains: input.domains } : {}
3100
+ });
3101
+ return {
3102
+ watch: registry.add(filter),
3103
+ unresolved,
3104
+ suggestions
3105
+ };
3106
+ }
3107
+ };
3108
+ const registerWatchTools = (server, service) => {
3109
+ server.registerTool("apple_messages_watch", {
3110
+ description: "Watch for new messages from chosen handles. Give `from` (email addresses, or phone numbers in international form such as +33612345678) and/or `domains` (email handles only; matches the domain and its subdomains); a sender is required, there is no watch on all messages. Returns a handle and its `uri`. Read the uri to see what arrived since the watch began (or since `after`): a host that subscribes is told when to read it, but many never do, so check it yourself rather than promising the user a notification. Metadata only, so fetch the text with apple_messages_get_message and the `id`. The watch lasts as long as this connection. Your own messages and tapbacks never match. Needs Full Disk Access; Messages does not have to be running.",
3111
+ inputSchema: z.object({
3112
+ from: z.array(z.string().min(1)).optional().describe("Email addresses, or phone numbers in international form (+33612345678: no trunk 0, no (0)); spaces and punctuation are ignored, but a local spelling like 06 12 34 56 78 never matches. An entry the store does not hold is kept without spaces or punctuation, lowercased, and listed in `unresolved`, which does not prove the person has not written: it fires only if the store later holds exactly that spelling. Near misses come back as `suggestions` to retry with."),
3113
+ domains: z.array(z.string().min(1)).optional().describe("Email domains, e.g. acme.com. Matches only email handles, never a phone number.")
3114
+ }),
3115
+ annotations: { readOnlyHint: true }
3116
+ }, async ({ from, domains }) => wrap(async () => {
3117
+ const { watch, unresolved, suggestions } = await service.add({
3118
+ from,
3119
+ domains
3120
+ });
3121
+ return {
3122
+ watch: watch.id,
3123
+ uri: watch.uri,
3124
+ filter: {
3125
+ from: watch.filter.from,
3126
+ domains: watch.filter.domains
3127
+ },
3128
+ cursor: watch.createdAt,
3129
+ unresolved,
3130
+ ...suggestions.length ? { suggestions } : {}
3131
+ };
3132
+ }));
3133
+ server.registerTool("apple_messages_unwatch", {
3134
+ description: "Stop a watch made by apple_messages_watch, by its `watch` id. Returns whether one was removed. Watches also end on their own when the connection closes, so this is only needed to free one of the ten a connection may hold.",
3135
+ inputSchema: z.object({ watch: z.string().min(1).describe("The `watch` id from apple_messages_watch.") }),
3136
+ annotations: { readOnlyHint: true }
3137
+ }, async ({ watch }) => wrap(async () => ({ removed: service.remove(watch) })));
3138
+ };
3139
+ //#endregion
2661
3140
  //#region src/tools/index.ts
2662
3141
  /**
2663
3142
  * Register the Apple Messages tools.
@@ -2666,7 +3145,8 @@ const registerMessageTools = (server, client) => {
2666
3145
  * surface the flag carries a permission claim as well as a safety one: with it
2667
3146
  * off no Apple Event is ever sent, so no Automation grant is ever requested.
2668
3147
  * What is needed either way is Full Disk Access, absolutely — see `diagnostics`.
2669
- * One further read, `find_codes`, only when `allowCodes` is on.
3148
+ * One further read, `find_codes`, only when `allowCodes` is on. Two more, the watch
3149
+ * tools, only when `allowWatches` is on (`ctx.watches`).
2670
3150
  *
2671
3151
  * The registered set is a pure function of STATIC CONFIGURATION, never of
2672
3152
  * runtime state. In particular it does NOT vary with whether the store is
@@ -2687,6 +3167,7 @@ const registerTools = (server, client, ctx) => {
2687
3167
  registerMessageTools(server, client);
2688
3168
  registerCountTools(server, client);
2689
3169
  if (ctx.allowCodes) registerCodeTools(server, client);
3170
+ if (ctx.watches) registerWatchTools(server, ctx.watches);
2690
3171
  if (!ctx.allowWrites) return;
2691
3172
  registerActionTools(server, client, ctx);
2692
3173
  registerAttachmentTools(server, client);
@@ -2713,6 +3194,47 @@ const createServer = (opts) => {
2713
3194
  ...opts.home ? { home: opts.home } : {},
2714
3195
  ...opts.contacts === void 0 ? {} : { contacts: opts.contacts }
2715
3196
  });
3197
+ const closers = [];
3198
+ let watches;
3199
+ if (config.allowWatches) {
3200
+ watches = new MessagesWatchService({
3201
+ client,
3202
+ ...opts.logger ? { logger: opts.logger } : {}
3203
+ });
3204
+ const service = watches;
3205
+ const registry = createWatches(server, {
3206
+ surface: "messages",
3207
+ displayName: "Messages",
3208
+ era: opts.era ?? "legacy",
3209
+ source: service.source,
3210
+ startTrigger: service.startTrigger
3211
+ });
3212
+ service.bind(registry);
3213
+ closers.push(() => {
3214
+ registry.close();
3215
+ service.close();
3216
+ });
3217
+ }
3218
+ let events;
3219
+ if (config.allowEvents) {
3220
+ events = new MessagesWatchService({
3221
+ client,
3222
+ mode: "events",
3223
+ ...opts.logger ? { logger: opts.logger } : {}
3224
+ });
3225
+ const service = events;
3226
+ const served = registerEvents(server, {
3227
+ era: opts.era ?? "legacy",
3228
+ adapters: [messagesEventAdapter(service)]
3229
+ });
3230
+ closers.push(() => {
3231
+ served.close();
3232
+ service.close();
3233
+ });
3234
+ }
3235
+ if (closers.length > 0) server.server.onclose = () => {
3236
+ for (const close of closers) close();
3237
+ };
2716
3238
  withLazyTools(server, {
2717
3239
  surface: "messages",
2718
3240
  displayName: "Messages",
@@ -2721,7 +3243,8 @@ const createServer = (opts) => {
2721
3243
  }, (target, allowWrites) => registerTools(target, client, {
2722
3244
  allowWrites,
2723
3245
  allowCodes: config.allowCodes,
2724
- allowFileSend: config.allowFileSend
3246
+ allowFileSend: config.allowFileSend,
3247
+ watches
2725
3248
  }));
2726
3249
  if (config.exposePrompts) {
2727
3250
  registerPrompts(server, config.allowWrites);
@@ -2734,10 +3257,12 @@ const createServer = (opts) => {
2734
3257
  }
2735
3258
  return {
2736
3259
  server,
2737
- client
3260
+ client,
3261
+ watches,
3262
+ events
2738
3263
  };
2739
3264
  };
2740
3265
  //#endregion
2741
3266
  export { MESSAGES_SURFACE as A, toAppleSeconds as B, locateStore as C, ChatNotFoundError as D, AppleMessagesError as E, SendTargetNotFoundError as F, CORE_DATA_EPOCH_OFFSET as I, appleSecondsSql as L, MessagesUnavailableError as M, SchemaDriftError$1 as N, IndexUnavailableError$1 as O, SendFailedError as P, fromAppleSeconds as R, defaultStorePath as S, PRELUDE as T, BUILD_INFO as V, decodeMessageRef as _, loadConfig$1 as a, ATTACHMENTS_RELATIVE as b, introspect as c, decodeAttributedBody as d, outline as f, decodeChatRef as g, MESSAGE_REF_VERSION as h, registerTools as i, MessageNotFoundError as j, MESSAGES_BUNDLE_ID as k, openStore as l, InvalidMessageRefError as m, SERVER_VERSION as n, AppleMessagesClient as o, CHAT_REF_VERSION as p, createServer as r, MessagesStore as s, SERVER_NAME as t, reactionLabel as u, encodeChatRef as v, SEND_MESSAGE as w, STORE_RELATIVE as x, encodeMessageRef as y, renderInstant as z };
2742
3267
 
2743
- //# sourceMappingURL=server-C3O37bOh.js.map
3268
+ //# sourceMappingURL=server-D-2jgV-B.js.map