@motionsmith/apple-messages-mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.1.1 — 2026-07-23
6
+
7
+ - Normalize formatted phone numbers when resolving Apple Messages conversations through Contacts.
8
+ - Match both chat identifiers and participant handles, then rank matching chats by recent activity.
9
+
5
10
  ## 0.1.0 — 2026-07-23
6
11
 
7
12
  - First public release of the Apple Messages MCP protocol library and reusable local macOS helper source.
package/README.md CHANGED
@@ -1,17 +1,27 @@
1
1
  # Apple Messages MCP
2
2
 
3
- `@motionsmith/apple-messages-mcp` is a library-first MCP protocol and reusable local macOS helper
4
- toolkit for Apple Messages. It is for any consumer that can host a stdio MCP server and provide its
5
- own status, watch setup, and optional bounded context callbacks. It is not a configuration store,
6
- approval system, transcript database, or Sugar integration.
3
+ `@motionsmith/apple-messages-mcp` helps a local app or agent read selected Apple Messages
4
+ conversations through MCP. It provides the MCP tools and a small macOS helper; your application
5
+ decides which conversations to watch, how they are configured, and what to do with the results.
7
6
 
8
7
  Apple Messages data stays on the local Mac. The helper reads the local `chat.db`; the app that
9
8
  compiles and runs it needs macOS Full Disk Access. This package never bypasses macOS privacy.
10
9
 
10
+ ## Is this for me?
11
+
12
+ This package is a good fit if you are building a local macOS assistant, automation, or MCP host and
13
+ want bounded, read-only access to chosen Messages conversations. It is especially useful when your
14
+ application already has its own configuration and can provide the callbacks shown below.
15
+
16
+ It is not a ready-to-use Messages app, a hosted service, or a no-code MCP server. It does not send
17
+ messages, sync conversations to the cloud, maintain a transcript archive, choose conversations for
18
+ the user, or avoid the Full Disk Access requirement. Live message reading works only on the Mac
19
+ where the Messages database exists.
20
+
11
21
  ## Install
12
22
 
13
23
  ```sh
14
- corepack pnpm add @motionsmith/apple-messages-mcp@0.1.0
24
+ corepack pnpm add @motionsmith/apple-messages-mcp@0.1.1
15
25
  ```
16
26
 
17
27
  The package supports Node 20 or newer and is useful for live helper work only on macOS. The
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const APPLE_MESSAGES_PROVIDER_VERSION = "0.1.0";
1
+ export declare const APPLE_MESSAGES_PROVIDER_VERSION = "0.1.1";
2
2
  export declare const MAX_APPLE_MESSAGES_CANDIDATES = 5;
3
3
  export type AppleMessagesReadStatus = 'ready' | 'unsupported' | 'missing_config' | 'missing_permission' | 'failed';
4
4
  export type AppleMessagesDirection = 'inbound' | 'outbound';
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ const os_1 = __importDefault(require("os"));
21
21
  const path_1 = __importDefault(require("path"));
22
22
  const readline_1 = require("readline");
23
23
  const process_1 = require("process");
24
- exports.APPLE_MESSAGES_PROVIDER_VERSION = '0.1.0';
24
+ exports.APPLE_MESSAGES_PROVIDER_VERSION = '0.1.1';
25
25
  exports.MAX_APPLE_MESSAGES_CANDIDATES = 5;
26
26
  function isAppleMessagesConversationRef(value) {
27
27
  return /^apple_messages_conversation_[a-f0-9]{32,64}$/.test(value);
@@ -85,11 +85,76 @@ func contactIdentifiers(_ query: String) -> [String] {
85
85
  return Array(identifiers).sorted().prefix(10).map { $0 }
86
86
  }
87
87
 
88
+ struct PhoneLookup {
89
+ let digits: String
90
+ let allowsCountryPrefix: Bool
91
+ }
92
+
93
+ func phoneLookup(_ value: String) -> PhoneLookup? {
94
+ let punctuation = CharacterSet(charactersIn: "+()- .")
95
+ guard value.unicodeScalars.allSatisfy({ CharacterSet.decimalDigits.contains($0) || punctuation.contains($0) }) else { return nil }
96
+ let digits = value.filter(\.isNumber)
97
+ guard digits.count >= 10 else { return nil }
98
+ return PhoneLookup(digits: digits, allowsCountryPrefix: !value.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("+"))
99
+ }
100
+
101
+ func normalizedIdentifierPredicate(_ expression: String, _ identifiers: [String]) -> String {
102
+ let emails = Set(identifiers.map { $0.lowercased() }.filter { $0.contains("@") }).sorted()
103
+ var phones: [String: Bool] = [:]
104
+ identifiers.compactMap(phoneLookup).forEach { phone in
105
+ phones[phone.digits] = (phones[phone.digits] ?? false) || phone.allowsCountryPrefix
106
+ }
107
+ var predicates = emails.map { "lower(\(expression)) = \(sqlQuote($0))" }
108
+ if !phones.isEmpty {
109
+ let normalized = "replace(replace(replace(replace(replace(replace(lower(\(expression)), '+', ''), '(', ''), ')', ''), '-', ''), ' ', ''), '.', '')"
110
+ predicates.append("\(normalized) IN (\(phones.keys.sorted().map(sqlQuote).joined(separator: ", ")))")
111
+ phones.filter(\.value).keys.sorted().forEach { digits in
112
+ predicates.append("(length(\(normalized)) > \(digits.count) AND length(\(normalized)) <= \(digits.count + 3) AND substr(\(normalized), -\(digits.count)) = \(sqlQuote(digits)))")
113
+ }
114
+ }
115
+ return predicates.joined(separator: " OR ")
116
+ }
117
+
118
+ func candidateMatchSelect(from: String, chatId: String, exact: String, contact: String, query: String) -> String {
119
+ let conditions = [exact, contact, query].filter { !$0.isEmpty }
120
+ var rank = "CASE WHEN (\(exact)) THEN 1"
121
+ if !contact.isEmpty { rank += " WHEN (\(contact)) THEN 2" }
122
+ rank += " ELSE 3 END"
123
+ return "SELECT \(chatId) AS chatId, \(rank) AS matchRank FROM \(from) WHERE \(conditions.map { "(\($0))" }.joined(separator: " OR "))"
124
+ }
125
+
88
126
  func candidatesSql(_ query: String, identifiers: [String], limit: Int) -> String {
89
- let value = sqlQuote(query); let contact = identifiers.map { "c.chat_identifier = \(sqlQuote($0))" }.joined(separator: " OR ")
90
- let contactCase = contact.isEmpty ? "" : " WHEN \(contact) THEN 'contact_match'"
91
- let contactWhere = contact.isEmpty ? "" : " OR \(contact)"
92
- return "SELECT DISTINCT c.guid AS chatGuid, CASE WHEN c.display_name = \(value) OR c.chat_identifier = \(value) THEN 'exact_label'\(contactCase) ELSE 'query_match' END AS matchKind FROM chat c WHERE instr(c.display_name, \(value)) > 0 OR instr(c.chat_identifier, \(value)) > 0\(contactWhere) ORDER BY c.ROWID DESC LIMIT \(min(max(limit, 1), 5));"
127
+ let value = sqlQuote(query)
128
+ let lookupIdentifiers = [query] + identifiers
129
+ let chatQuery = ["instr(lower(COALESCE(c.display_name, '')), lower(\(value))) > 0", "instr(lower(COALESCE(c.chat_identifier, '')), lower(\(value))) > 0", normalizedIdentifierPredicate("c.chat_identifier", lookupIdentifiers)].filter { !$0.isEmpty }.joined(separator: " OR ")
130
+ let participantQuery = ["instr(lower(h.id), lower(\(value))) > 0", normalizedIdentifierPredicate("h.id", lookupIdentifiers)].filter { !$0.isEmpty }.joined(separator: " OR ")
131
+ let messageQuery = ["instr(lower(mh.id), lower(\(value))) > 0", normalizedIdentifierPredicate("mh.id", lookupIdentifiers)].filter { !$0.isEmpty }.joined(separator: " OR ")
132
+ let chatSelect = candidateMatchSelect(
133
+ from: "chat c",
134
+ chatId: "c.ROWID",
135
+ exact: "lower(c.display_name) = lower(\(value)) OR lower(c.chat_identifier) = lower(\(value))",
136
+ contact: normalizedIdentifierPredicate("c.chat_identifier", identifiers),
137
+ query: chatQuery
138
+ )
139
+ let participantSelect = candidateMatchSelect(
140
+ from: "chat_handle_join chj JOIN handle h ON h.ROWID = chj.handle_id",
141
+ chatId: "chj.chat_id",
142
+ exact: "lower(h.id) = lower(\(value))",
143
+ contact: normalizedIdentifierPredicate("h.id", identifiers),
144
+ query: participantQuery
145
+ )
146
+ let messageSelect = candidateMatchSelect(
147
+ from: "message m JOIN handle mh ON mh.ROWID = m.handle_id JOIN chat_message_join cmj ON cmj.message_id = m.ROWID",
148
+ chatId: "cmj.chat_id",
149
+ exact: "lower(mh.id) = lower(\(value))",
150
+ contact: normalizedIdentifierPredicate("mh.id", identifiers),
151
+ query: messageQuery
152
+ )
153
+ return "WITH matches AS (\(chatSelect) UNION ALL \(participantSelect) UNION ALL \(messageSelect)), ranked AS (SELECT chatId, MIN(matchRank) AS matchRank FROM matches GROUP BY chatId) SELECT c.guid AS chatGuid, CASE ranked.matchRank WHEN 1 THEN 'exact_label' WHEN 2 THEN 'contact_match' ELSE 'query_match' END AS matchKind FROM ranked JOIN chat c ON c.ROWID = ranked.chatId ORDER BY COALESCE((SELECT MAX(m.date) FROM chat_message_join cmj JOIN message m ON m.ROWID = cmj.message_id WHERE cmj.chat_id = c.ROWID), 0) DESC, c.ROWID DESC LIMIT \(min(max(limit, 1), 5));"
154
+ }
155
+
156
+ func shouldResolveContacts(_ query: String) -> Bool {
157
+ return !query.contains("@") && query.contains(where: \.isLetter)
93
158
  }
94
159
 
95
160
  func readSql(_ conversation: String, _ maxMessages: Int) -> String {
@@ -107,7 +172,8 @@ do {
107
172
  if command == "messages-candidates" {
108
173
  let query = ((request["query"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
109
174
  guard query.count >= 2 else { result(candidateKind, status: "failed", findings: ["Apple Messages candidate lookup requires a query of at least two characters."]); exit(64) }
110
- let response = try sqlite(path, candidatesSql(query, identifiers: contactIdentifiers(query), limit: (request["limit"] as? Int) ?? 5))
175
+ let identifiers = shouldResolveContacts(query) ? contactIdentifiers(query) : []
176
+ let response = try sqlite(path, candidatesSql(query, identifiers: identifiers, limit: (request["limit"] as? Int) ?? 5))
111
177
  guard response.status == 0 else { let status = failureStatus(response.stderr); result(candidateKind, status: status, findings: [failureFinding(status)]); exit(status == "missing_permission" ? 3 : 2) }
112
178
  let rows = ((try? JSONSerialization.jsonObject(with: Data(response.stdout.utf8))) as? [[String: Any]]) ?? []
113
179
  let candidates = rows.enumerated().compactMap { index, row -> [String: Any]? in guard let guid = row["chatGuid"] as? String, let kind = row["matchKind"] as? String else { return nil }; return ["candidateRef": opaqueConversationRef(guid), "label": "Conversation \(index + 1)", "matchKind": kind == "exact_label" ? "exact_label" : kind == "contact_match" ? "contact_match" : "query_match"] }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionsmith/apple-messages-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Library-first MCP protocol and local macOS helper toolkit for Apple Messages.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -31,6 +31,13 @@
31
31
  "url": "https://github.com/motionsmith/apple-messages-mcp/issues"
32
32
  },
33
33
  "homepage": "https://github.com/motionsmith/apple-messages-mcp#readme",
34
+ "scripts": {
35
+ "build": "tsc -b",
36
+ "test": "vitest run",
37
+ "lint": "eslint . --ext .ts",
38
+ "helper:build": "scripts/build-apple-messages-helper.sh",
39
+ "pack:check": "pnpm pack --pack-destination /tmp/apple-messages-mcp-pack"
40
+ },
34
41
  "devDependencies": {
35
42
  "@eslint/js": "^9.39.1",
36
43
  "@types/node": "^22.15.0",
@@ -40,11 +47,5 @@
40
47
  "typescript-eslint": "^8.59.4",
41
48
  "vitest": "^4.1.6"
42
49
  },
43
- "scripts": {
44
- "build": "tsc -b",
45
- "test": "vitest run",
46
- "lint": "eslint . --ext .ts",
47
- "helper:build": "scripts/build-apple-messages-helper.sh",
48
- "pack:check": "pnpm pack --pack-destination /tmp/apple-messages-mcp-pack"
49
- }
50
- }
50
+ "packageManager": "pnpm@11.1.2"
51
+ }
@@ -13,7 +13,7 @@
13
13
  <key>CFBundlePackageType</key>
14
14
  <string>APPL</string>
15
15
  <key>CFBundleShortVersionString</key>
16
- <string>0.1.0</string>
16
+ <string>0.1.1</string>
17
17
  <key>LSUIElement</key>
18
18
  <true/>
19
19
  </dict>
File without changes