@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`
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
90
|
-
let
|
|
91
|
-
let
|
|
92
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
44
|
-
|
|
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
|
+
}
|
|
File without changes
|