@motionsmith/apple-messages-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented here.
4
+
5
+ ## 0.1.0 — 2026-07-23
6
+
7
+ - First public release of the Apple Messages MCP protocol library and reusable local macOS helper source.
8
+ - Defines protocol v1: `messages_status`, `setup_messages_watch`, and `read_messages_context`.
9
+ - Adds opaque conversation-reference validation, five-candidate ambiguity cap, sanitised MCP errors, and redacted message normalization.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Motionsmith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # Apple Messages MCP
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.
7
+
8
+ Apple Messages data stays on the local Mac. The helper reads the local `chat.db`; the app that
9
+ compiles and runs it needs macOS Full Disk Access. This package never bypasses macOS privacy.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ corepack pnpm add @motionsmith/apple-messages-mcp@0.1.0
15
+ ```
16
+
17
+ The package supports Node 20 or newer and is useful for live helper work only on macOS. The
18
+ library's fixture and protocol APIs are cross-platform.
19
+
20
+ ## Host the stdio MCP server
21
+
22
+ The consumer owns callbacks and their policies. In particular, it decides whether setup writes
23
+ configuration, whether a user approves that write, what a status response contains, and whether
24
+ bounded context is persisted or displayed.
25
+
26
+ ```js
27
+ import { runAppleMessagesMcpStdioServer } from '@motionsmith/apple-messages-mcp';
28
+
29
+ await runAppleMessagesMcpStdioServer({
30
+ callbacks: {
31
+ status: async () => ({ status: 'not_configured' }),
32
+ setup: async ({ watch, candidate, alias, approve }) => {
33
+ // Resolve and store a watch in this application's own configuration.
34
+ return { status: 'dry_run', watch, candidate, alias, approve: approve === true };
35
+ },
36
+ readContext: async ({ conversationRef, limit = 5 }) => {
37
+ // Read only this application's configured opaque ref, bounded by `limit`.
38
+ return { status: 'ready', conversationRef, limit, messages: [] };
39
+ },
40
+ },
41
+ });
42
+ ```
43
+
44
+ This server reads newline-delimited JSON-RPC on stdin and writes newline-delimited JSON-RPC on
45
+ stdout. The package intentionally has no default executable: a consumer cannot use it safely
46
+ without owning those callbacks.
47
+
48
+ ## Protocol v1
49
+
50
+ The MCP server handles `initialize`, `notifications/initialized`, `tools/list`, and `tools/call`.
51
+ Tool calls are:
52
+
53
+ - `messages_status` — calls the consumer's `status` callback.
54
+ - `setup_messages_watch` — calls `setup` with `watch`, optional opaque `candidate`, optional
55
+ `alias`, and explicit `approve: true` only when supplied.
56
+ - `read_messages_context` — calls optional `readContext` with a required opaque
57
+ `apple_messages_conversation_*` ref and a limit from 1 through 20.
58
+
59
+ Candidate payloads are validated and capped at five. Invalid requests and callback failures produce
60
+ sanitised MCP errors. The library normalizes fixture or helper rows to opaque refs and hashes; it
61
+ does not emit raw message bodies, handles, chat GUIDs, lookup queries, or attachment contents.
62
+ See [the compatibility policy](docs/compatibility.md).
63
+
64
+ ## Helper template
65
+
66
+ The reusable helper source is `helper/apple-messages-helper.swift`; its protocol commands are
67
+ `messages-candidates`, `messages-validate`, and `messages-read`. Build the default standalone host
68
+ app on macOS:
69
+
70
+ ```sh
71
+ corepack pnpm helper:build
72
+ ```
73
+
74
+ This creates `bin/Apple Messages MCP Host.app`, bundle id
75
+ `com.motionsmith.apple-messages-mcp.host`. Grant that app Full Disk Access in System Settings >
76
+ Privacy & Security before a live `chat.db` read. A consumer may compile the included source into
77
+ its own host app identity; it then owns installation and recovery language for that identity.
78
+
79
+ ## Safe fixture verification
80
+
81
+ Use the supplied consumer example after installing from a packed artifact. It hosts callbacks only
82
+ and does not read live Apple Messages data:
83
+
84
+ ```sh
85
+ corepack pnpm exec node examples/consumer-smoke.mjs --stdio <<'EOF'
86
+ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
87
+ {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
88
+ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"messages_status","arguments":{}}}
89
+ EOF
90
+ ```
91
+
92
+ Expected results include the `apple-messages` server info, the three protocol-v1 tools, and a
93
+ `not_configured` callback response. Configure a non-sensitive test thread only for a manual live
94
+ smoke. Do not add raw messages or contact identifiers to fixtures, screenshots, CI logs, issue
95
+ reports, or package documentation.
96
+
97
+ ## Development and releases
98
+
99
+ ```sh
100
+ corepack pnpm install
101
+ corepack pnpm lint
102
+ corepack pnpm test
103
+ corepack pnpm build
104
+ corepack pnpm pack
105
+ ```
106
+
107
+ CI runs the frozen install, lint, tests, TypeScript build, package-content check, and the macOS
108
+ helper compile/dispatch smoke. Tag releases use npm provenance after the repository's `publish`
109
+ environment has been configured as the npm trusted publisher. See
110
+ [release policy](docs/release-policy.md) and [CHANGELOG.md](CHANGELOG.md).
111
+
112
+ ## Notices
113
+
114
+ This package is not affiliated with Apple or OpenAI. Apple Messages and macOS are Apple platforms.
115
+ Codex and OpenAI are separate products and services.
@@ -0,0 +1,143 @@
1
+ export declare const APPLE_MESSAGES_PROVIDER_VERSION = "0.1.0";
2
+ export declare const MAX_APPLE_MESSAGES_CANDIDATES = 5;
3
+ export type AppleMessagesReadStatus = 'ready' | 'unsupported' | 'missing_config' | 'missing_permission' | 'failed';
4
+ export type AppleMessagesDirection = 'inbound' | 'outbound';
5
+ export type AppleMessagesContentMode = 'hash_only' | 'metadata_only';
6
+ export interface AppleMessagesSourceReadInput {
7
+ scope: string;
8
+ sourceId: string;
9
+ conversationLabel: string;
10
+ conversationRef?: string;
11
+ maxMessages: number;
12
+ }
13
+ export type AppleMessagesSourceRead = (input: AppleMessagesSourceReadInput) => Promise<AppleMessagesReadResult>;
14
+ export interface AppleMessagesReadResult {
15
+ schemaVersion: 1;
16
+ status: AppleMessagesReadStatus;
17
+ messages: NormalizedAppleMessage[];
18
+ providerCursor?: string;
19
+ findings: string[];
20
+ }
21
+ export interface NormalizedAppleMessage {
22
+ source: 'apple_messages';
23
+ id: string;
24
+ conversation: {
25
+ ref: string;
26
+ label: string;
27
+ };
28
+ direction: AppleMessagesDirection;
29
+ service: 'iMessage' | 'SMS' | 'unknown' | string;
30
+ sentAt: string;
31
+ senderRef?: string;
32
+ attachmentCount: number;
33
+ content: {
34
+ mode: AppleMessagesContentMode;
35
+ hash?: string;
36
+ };
37
+ editedAt?: string;
38
+ deletedAt?: string;
39
+ unsupported: {
40
+ reactions: boolean;
41
+ };
42
+ }
43
+ export interface AppleMessagesFixtureReadInput {
44
+ conversation: {
45
+ label: string;
46
+ refSeed?: string;
47
+ };
48
+ rows: AppleMessagesFixtureRow[];
49
+ }
50
+ export interface AppleMessagesFixtureRow {
51
+ messageId: string;
52
+ chatGuid: string;
53
+ isFromMe: boolean;
54
+ service?: string;
55
+ sentAt: string;
56
+ handleValue?: string;
57
+ text?: string;
58
+ attachmentCount?: number;
59
+ editedAt?: string;
60
+ deletedAt?: string;
61
+ hasReactions?: boolean;
62
+ }
63
+ export interface AppleMessagesSqliteRow {
64
+ messageId: number | string;
65
+ chatGuid: string;
66
+ isFromMe: number | string | boolean;
67
+ service?: string | null;
68
+ appleDate: number | string | null;
69
+ handleValue?: string | null;
70
+ text?: string | null;
71
+ attachmentCount?: number | string | null;
72
+ dateEdited?: number | string | null;
73
+ dateDeleted?: number | string | null;
74
+ }
75
+ export interface AppleMessagesCandidate {
76
+ candidateRef: string;
77
+ label: string;
78
+ matchKind: 'exact_label' | 'query_match' | 'contact_match';
79
+ }
80
+ export interface AppleMessagesHelperPayload {
81
+ schemaVersion?: number;
82
+ kind?: string;
83
+ status?: AppleMessagesReadStatus;
84
+ rows?: AppleMessagesSqliteRow[];
85
+ candidates?: unknown[];
86
+ findings?: string[];
87
+ }
88
+ export interface AppleMessagesHelperReadOptions {
89
+ invoke: (input: Record<string, unknown>) => Promise<unknown>;
90
+ }
91
+ export interface AppleMessagesChatDbReadOptions {
92
+ databasePath?: string;
93
+ runner: (command: string, args: string[], options: {
94
+ timeoutMs?: number;
95
+ }) => Promise<{
96
+ exitCode: number;
97
+ stdout: string;
98
+ stderr: string;
99
+ }>;
100
+ timeoutMs?: number;
101
+ }
102
+ export declare function isAppleMessagesConversationRef(value: string): boolean;
103
+ export declare function normalizeAppleMessagesFixtureReadResult(input: AppleMessagesFixtureReadInput): AppleMessagesReadResult;
104
+ export declare function normalizeAppleMessagesHelperReadResult(payload: unknown, conversationLabel: string, readerLabel?: string): AppleMessagesReadResult;
105
+ export declare function readAppleMessagesHelper(input: AppleMessagesSourceReadInput, options: AppleMessagesHelperReadOptions): Promise<AppleMessagesReadResult>;
106
+ export declare function createAppleMessagesChatDbRead(options: AppleMessagesChatDbReadOptions): AppleMessagesSourceRead;
107
+ export declare function parseAppleMessagesCandidates(payload: unknown): AppleMessagesCandidate[];
108
+ export declare function appleMessagesHelperStatus(payload: unknown): AppleMessagesReadStatus | undefined;
109
+ export declare function appleMessagesHelperFindings(payload: unknown): string[];
110
+ export interface AppleMessagesMcpRequest {
111
+ id?: unknown;
112
+ method?: unknown;
113
+ params?: unknown;
114
+ }
115
+ export interface AppleMessagesMcpCallbacks {
116
+ status: () => Promise<unknown>;
117
+ setup: (input: {
118
+ watch: string;
119
+ candidate?: string;
120
+ alias?: string;
121
+ approve?: boolean;
122
+ }) => Promise<unknown>;
123
+ readContext?: (input: {
124
+ conversationRef: string;
125
+ limit?: number;
126
+ }) => Promise<unknown>;
127
+ }
128
+ export interface AppleMessagesMcpStdioServerOptions {
129
+ stdin?: NodeJS.ReadableStream;
130
+ stdout?: NodeJS.WritableStream;
131
+ callbacks: AppleMessagesMcpCallbacks;
132
+ }
133
+ export declare function appleMessagesMcpTools(): {
134
+ name: string;
135
+ description: string;
136
+ inputSchema: {
137
+ type: string;
138
+ additionalProperties: boolean;
139
+ properties: {};
140
+ };
141
+ }[];
142
+ export declare function handleAppleMessagesMcpRequest(request: AppleMessagesMcpRequest, callbacks: AppleMessagesMcpCallbacks): Promise<unknown>;
143
+ export declare function runAppleMessagesMcpStdioServer(options: AppleMessagesMcpStdioServerOptions): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,432 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MAX_APPLE_MESSAGES_CANDIDATES = exports.APPLE_MESSAGES_PROVIDER_VERSION = void 0;
7
+ exports.isAppleMessagesConversationRef = isAppleMessagesConversationRef;
8
+ exports.normalizeAppleMessagesFixtureReadResult = normalizeAppleMessagesFixtureReadResult;
9
+ exports.normalizeAppleMessagesHelperReadResult = normalizeAppleMessagesHelperReadResult;
10
+ exports.readAppleMessagesHelper = readAppleMessagesHelper;
11
+ exports.createAppleMessagesChatDbRead = createAppleMessagesChatDbRead;
12
+ exports.parseAppleMessagesCandidates = parseAppleMessagesCandidates;
13
+ exports.appleMessagesHelperStatus = appleMessagesHelperStatus;
14
+ exports.appleMessagesHelperFindings = appleMessagesHelperFindings;
15
+ exports.appleMessagesMcpTools = appleMessagesMcpTools;
16
+ exports.handleAppleMessagesMcpRequest = handleAppleMessagesMcpRequest;
17
+ exports.runAppleMessagesMcpStdioServer = runAppleMessagesMcpStdioServer;
18
+ const crypto_1 = require("crypto");
19
+ const promises_1 = __importDefault(require("fs/promises"));
20
+ const os_1 = __importDefault(require("os"));
21
+ const path_1 = __importDefault(require("path"));
22
+ const readline_1 = require("readline");
23
+ const process_1 = require("process");
24
+ exports.APPLE_MESSAGES_PROVIDER_VERSION = '0.1.0';
25
+ exports.MAX_APPLE_MESSAGES_CANDIDATES = 5;
26
+ function isAppleMessagesConversationRef(value) {
27
+ return /^apple_messages_conversation_[a-f0-9]{32,64}$/.test(value);
28
+ }
29
+ function normalizeAppleMessagesFixtureReadResult(input) {
30
+ const conversationSeed = input.conversation.refSeed ?? input.rows[0]?.chatGuid ?? `fixture:${input.conversation.label}`;
31
+ const conversationRef = `apple_messages_conversation_${hashText(conversationSeed).slice(0, 32)}`;
32
+ const messages = input.rows
33
+ .map((row) => normalizeFixtureRow(row, input.conversation.label, conversationRef))
34
+ .sort((left, right) => left.sentAt.localeCompare(right.sentAt) || left.id.localeCompare(right.id));
35
+ return {
36
+ schemaVersion: 1,
37
+ status: 'ready',
38
+ messages,
39
+ providerCursor: providerCursorForMessages(messages),
40
+ findings: [],
41
+ };
42
+ }
43
+ function normalizeAppleMessagesHelperReadResult(payload, conversationLabel, readerLabel = 'Apple Messages host helper') {
44
+ const helper = parseHelperPayload(payload);
45
+ if (helper === undefined)
46
+ return failedRead('Apple Messages host helper returned malformed JSON.');
47
+ if (helper.status !== 'ready') {
48
+ return {
49
+ schemaVersion: 1,
50
+ status: helper.status ?? 'failed',
51
+ messages: [],
52
+ findings: nonEmptyFindings(helper.findings, `Apple Messages host helper failed before producing normalized records: ${helper.status ?? 'failed'}`),
53
+ };
54
+ }
55
+ return appleMessagesReadResultFromSqliteRows(helper.rows ?? [], conversationLabel, readerLabel);
56
+ }
57
+ async function readAppleMessagesHelper(input, options) {
58
+ return normalizeAppleMessagesHelperReadResult(await options.invoke({
59
+ ...(input.conversationRef === undefined
60
+ ? { conversationLabel: input.conversationLabel }
61
+ : { conversationRef: input.conversationRef }),
62
+ maxMessages: input.maxMessages,
63
+ }), input.conversationLabel);
64
+ }
65
+ function createAppleMessagesChatDbRead(options) {
66
+ const databasePath = options.databasePath ?? path_1.default.join(os_1.default.homedir(), 'Library', 'Messages', 'chat.db');
67
+ const timeoutMs = options.timeoutMs ?? 5_000;
68
+ return async (input) => {
69
+ try {
70
+ await promises_1.default.access(databasePath);
71
+ }
72
+ catch (error) {
73
+ const status = isPermissionError(error) ? 'missing_permission' : 'failed';
74
+ return failedWithStatus(status, status === 'missing_permission'
75
+ ? 'Apple Messages chat.db is not readable; grant Full Disk Access to the configured Apple Messages host app.'
76
+ : 'Apple Messages chat.db was not found at the expected local path.');
77
+ }
78
+ const response = await options.runner('sqlite3', ['-readonly', '-json', databasePath, messagesSql(input.conversationLabel, input.maxMessages)], { timeoutMs });
79
+ if (response.exitCode !== 0) {
80
+ const status = sqliteFailureStatus(response.stderr);
81
+ return failedWithStatus(status, sqliteFailureFinding(status));
82
+ }
83
+ try {
84
+ const rows = response.stdout.trim().length === 0
85
+ ? []
86
+ : JSON.parse(response.stdout);
87
+ return normalizeAppleMessagesHelperReadResult({ status: 'ready', rows }, input.conversationLabel, 'chat.db reader');
88
+ }
89
+ catch {
90
+ return failedRead('Apple Messages chat.db reader returned malformed JSON.');
91
+ }
92
+ };
93
+ }
94
+ function parseAppleMessagesCandidates(payload) {
95
+ const candidates = parseHelperPayload(payload)?.candidates ?? [];
96
+ return candidates
97
+ .filter((candidate) => isRecord(candidate) &&
98
+ typeof candidate.candidateRef === 'string' &&
99
+ isAppleMessagesConversationRef(candidate.candidateRef) &&
100
+ typeof candidate.label === 'string' &&
101
+ (candidate.matchKind === 'exact_label' ||
102
+ candidate.matchKind === 'query_match' ||
103
+ candidate.matchKind === 'contact_match'))
104
+ .slice(0, exports.MAX_APPLE_MESSAGES_CANDIDATES);
105
+ }
106
+ function appleMessagesHelperStatus(payload) {
107
+ return parseHelperPayload(payload)?.status;
108
+ }
109
+ function appleMessagesHelperFindings(payload) {
110
+ return nonEmptyFindings(parseHelperPayload(payload)?.findings);
111
+ }
112
+ function appleMessagesMcpTools() {
113
+ return [
114
+ {
115
+ name: 'messages_status',
116
+ description: 'Report every configured Apple Messages watch and its checkpoint health.',
117
+ inputSchema: emptySchema(),
118
+ },
119
+ {
120
+ name: 'setup_messages_watch',
121
+ description: 'Resolve a bounded Messages candidate and, only with approve=true, add the selected watch.',
122
+ inputSchema: setupSchema(),
123
+ },
124
+ {
125
+ name: 'read_messages_context',
126
+ description: 'Read a bounded recent transcript for one already configured opaque conversation reference.',
127
+ inputSchema: contextSchema(),
128
+ },
129
+ ];
130
+ }
131
+ async function handleAppleMessagesMcpRequest(request, callbacks) {
132
+ if (request.method === 'initialize')
133
+ return {
134
+ protocolVersion: '2024-11-05',
135
+ capabilities: { tools: {} },
136
+ serverInfo: { name: 'apple-messages', version: exports.APPLE_MESSAGES_PROVIDER_VERSION },
137
+ };
138
+ if (request.method === 'notifications/initialized')
139
+ return undefined;
140
+ if (request.method === 'tools/list')
141
+ return {
142
+ tools: appleMessagesMcpTools().map((tool) => ({
143
+ ...tool,
144
+ annotations: {
145
+ readOnlyHint: tool.name !== 'setup_messages_watch',
146
+ destructiveHint: false,
147
+ idempotentHint: tool.name !== 'setup_messages_watch',
148
+ openWorldHint: true,
149
+ },
150
+ })),
151
+ };
152
+ if (request.method !== 'tools/call' ||
153
+ !isRecord(request.params) ||
154
+ typeof request.params.name !== 'string')
155
+ return { error: { code: -32601, message: 'Method not found' } };
156
+ if (request.params.name === 'messages_status')
157
+ return success(await callbacks.status());
158
+ if (request.params.name === 'read_messages_context') {
159
+ if (callbacks.readContext === undefined)
160
+ return failure('invalid_input', 'read_messages_context is unavailable.');
161
+ if (!isRecord(request.params.arguments) ||
162
+ typeof request.params.arguments.conversationRef !== 'string')
163
+ return failure('invalid_input', 'read_messages_context requires conversationRef.');
164
+ const limit = request.params.arguments.limit;
165
+ if (limit !== undefined &&
166
+ (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 20))
167
+ return failure('invalid_input', 'read_messages_context limit must be between 1 and 20.');
168
+ return success(await callbacks.readContext({
169
+ conversationRef: request.params.arguments.conversationRef,
170
+ ...(typeof limit === 'number' ? { limit } : {}),
171
+ }));
172
+ }
173
+ if (request.params.name !== 'setup_messages_watch' ||
174
+ !isRecord(request.params.arguments) ||
175
+ typeof request.params.arguments.watch !== 'string')
176
+ return failure('invalid_input', 'setup_messages_watch requires watch.');
177
+ const input = request.params.arguments;
178
+ const watch = input.watch;
179
+ if (typeof watch !== 'string')
180
+ return failure('invalid_input', 'setup_messages_watch requires watch.');
181
+ return success(await callbacks.setup({
182
+ watch,
183
+ ...(typeof input.candidate === 'string' ? { candidate: input.candidate } : {}),
184
+ ...(typeof input.alias === 'string' ? { alias: input.alias } : {}),
185
+ ...(input.approve === true ? { approve: true } : {}),
186
+ }));
187
+ }
188
+ async function runAppleMessagesMcpStdioServer(options) {
189
+ const reader = (0, readline_1.createInterface)({ input: options.stdin ?? process_1.stdin, crlfDelay: Infinity });
190
+ const stdout = options.stdout ?? process_1.stdout;
191
+ for await (const line of reader) {
192
+ if (line.trim().length === 0)
193
+ continue;
194
+ const request = parseRequest(line);
195
+ if (request === undefined) {
196
+ write(stdout, null, { error: { code: -32700, message: 'Parse error' } });
197
+ continue;
198
+ }
199
+ try {
200
+ const response = await handleAppleMessagesMcpRequest(request, options.callbacks);
201
+ if (response !== undefined)
202
+ write(stdout, request.id ?? null, response);
203
+ }
204
+ catch {
205
+ write(stdout, request.id ?? null, failure('tool_execution_failed', 'Apple Messages tool request could not be completed.'));
206
+ }
207
+ }
208
+ }
209
+ function appleMessagesReadResultFromSqliteRows(rows, conversationLabel, readerLabel) {
210
+ if (rows.length === 0)
211
+ return {
212
+ schemaVersion: 1,
213
+ status: 'missing_config',
214
+ messages: [],
215
+ findings: [
216
+ 'No Apple Messages conversation matched the configured watched conversation label.',
217
+ ],
218
+ };
219
+ try {
220
+ const messages = rows
221
+ .map((row) => normalizeSqliteRow(row, conversationLabel))
222
+ .sort((left, right) => left.sentAt.localeCompare(right.sentAt) || left.id.localeCompare(right.id));
223
+ return {
224
+ schemaVersion: 1,
225
+ status: 'ready',
226
+ messages,
227
+ providerCursor: providerCursorForMessages(messages),
228
+ findings: [],
229
+ };
230
+ }
231
+ catch {
232
+ return failedRead(`Apple Messages ${readerLabel} returned invalid message records.`);
233
+ }
234
+ }
235
+ function normalizeSqliteRow(row, conversationLabel) {
236
+ const chatGuid = requireNonEmptyValue(row.chatGuid, 'Apple Messages chat guid');
237
+ return normalizeFixtureRow({
238
+ messageId: requireNonEmptyValue(String(row.messageId), 'Apple Messages message id'),
239
+ chatGuid,
240
+ isFromMe: row.isFromMe === true || row.isFromMe === 1 || row.isFromMe === '1',
241
+ service: row.service ?? undefined,
242
+ sentAt: appleMessageDateToIso(row.appleDate, 'Apple Messages message date'),
243
+ handleValue: row.handleValue ?? undefined,
244
+ text: row.text ?? undefined,
245
+ attachmentCount: integerFromSqlite(row.attachmentCount),
246
+ editedAt: row.dateEdited === undefined || row.dateEdited === null || Number(row.dateEdited) === 0
247
+ ? undefined
248
+ : appleMessageDateToIso(row.dateEdited, 'Apple Messages edit date'),
249
+ deletedAt: row.dateDeleted === undefined || row.dateDeleted === null || Number(row.dateDeleted) === 0
250
+ ? undefined
251
+ : appleMessageDateToIso(row.dateDeleted, 'Apple Messages delete date'),
252
+ hasReactions: false,
253
+ }, conversationLabel, `apple_messages_conversation_${hashText(chatGuid).slice(0, 32)}`);
254
+ }
255
+ function normalizeFixtureRow(row, conversationLabel, conversationRef) {
256
+ assertNonEmptyString(row.messageId, 'Apple Messages fixture messageId');
257
+ assertNonEmptyString(row.chatGuid, 'Apple Messages fixture chatGuid');
258
+ const sentAt = isoTimestamp(row.sentAt, 'Apple Messages fixture sentAt');
259
+ const text = row.text ?? '';
260
+ return {
261
+ source: 'apple_messages',
262
+ id: `apple_message_${hashText(`${row.chatGuid}:${row.messageId}`).slice(0, 32)}`,
263
+ conversation: { ref: conversationRef, label: conversationLabel },
264
+ direction: row.isFromMe ? 'outbound' : 'inbound',
265
+ service: normalizeService(row.service),
266
+ sentAt,
267
+ ...(row.handleValue === undefined
268
+ ? {}
269
+ : { senderRef: `apple_messages_sender_${hashText(row.handleValue).slice(0, 32)}` }),
270
+ attachmentCount: row.attachmentCount ?? 0,
271
+ content: text.length === 0 ? { mode: 'metadata_only' } : { mode: 'hash_only', hash: hashText(text) },
272
+ ...(row.editedAt === undefined
273
+ ? {}
274
+ : { editedAt: isoTimestamp(row.editedAt, 'Apple Messages fixture editedAt') }),
275
+ ...(row.deletedAt === undefined
276
+ ? {}
277
+ : { deletedAt: isoTimestamp(row.deletedAt, 'Apple Messages fixture deletedAt') }),
278
+ unsupported: { reactions: row.hasReactions === true },
279
+ };
280
+ }
281
+ function parseHelperPayload(payload) {
282
+ if (!isRecord(payload))
283
+ return undefined;
284
+ return {
285
+ status: validStatus(payload.status) ? payload.status : undefined,
286
+ rows: Array.isArray(payload.rows) ? payload.rows : undefined,
287
+ candidates: Array.isArray(payload.candidates) ? payload.candidates : undefined,
288
+ findings: Array.isArray(payload.findings)
289
+ ? payload.findings.filter((value) => typeof value === 'string')
290
+ : undefined,
291
+ };
292
+ }
293
+ function validStatus(value) {
294
+ return (value === 'ready' ||
295
+ value === 'unsupported' ||
296
+ value === 'missing_config' ||
297
+ value === 'missing_permission' ||
298
+ value === 'failed');
299
+ }
300
+ function nonEmptyFindings(findings, fallback) {
301
+ const value = findings?.filter((finding) => finding.trim().length > 0) ?? [];
302
+ return value.length > 0 ? value : fallback === undefined ? [] : [fallback];
303
+ }
304
+ function failedRead(finding) {
305
+ return { schemaVersion: 1, status: 'failed', messages: [], findings: [finding] };
306
+ }
307
+ function failedWithStatus(status, finding) {
308
+ return { schemaVersion: 1, status, messages: [], findings: [finding] };
309
+ }
310
+ function isPermissionError(error) {
311
+ return (error instanceof Error &&
312
+ 'code' in error &&
313
+ (error.code === 'EACCES' ||
314
+ error.code === 'EPERM'));
315
+ }
316
+ function messagesSql(conversationLabel, maxMessages) {
317
+ const value = `'${conversationLabel.replace(/'/g, "''")}'`;
318
+ return [
319
+ `SELECT m.ROWID AS messageId, c.guid AS chatGuid, m.is_from_me AS isFromMe, m.service AS service, m.date AS appleDate, h.id AS handleValue, m.text AS text, COALESCE(m.cache_has_attachments, 0) AS attachmentCount, COALESCE(m.date_edited, 0) AS dateEdited, COALESCE(m.date_retracted, 0) AS dateDeleted`,
320
+ 'FROM chat c JOIN chat_message_join cmj ON cmj.chat_id = c.ROWID JOIN message m ON m.ROWID = cmj.message_id LEFT JOIN handle h ON h.ROWID = m.handle_id',
321
+ `WHERE c.display_name = ${value} OR c.chat_identifier = ${value} OR c.guid = ${value}`,
322
+ 'ORDER BY m.date DESC, m.ROWID DESC',
323
+ `LIMIT ${Math.max(1, Math.min(maxMessages, 500))};`,
324
+ ].join('\n');
325
+ }
326
+ function sqliteFailureStatus(stderr) {
327
+ const lower = stderr.toLowerCase();
328
+ if (lower.includes('authorization denied') ||
329
+ lower.includes('not authorized') ||
330
+ lower.includes('permission denied') ||
331
+ lower.includes('unable to open database'))
332
+ return 'missing_permission';
333
+ if (lower.includes('command not found') || lower.includes('no such file'))
334
+ return 'unsupported';
335
+ return 'failed';
336
+ }
337
+ function sqliteFailureFinding(status) {
338
+ if (status === 'missing_permission')
339
+ return 'Apple Messages chat.db is not readable; grant Full Disk Access to the configured Apple Messages host app.';
340
+ if (status === 'unsupported')
341
+ return 'Apple Messages live reads require the sqlite3 command on this Mac.';
342
+ return 'Apple Messages chat.db reader failed before producing normalized records.';
343
+ }
344
+ function providerCursorForMessages(messages) {
345
+ return messages
346
+ .map((message) => message.sentAt)
347
+ .sort()
348
+ .at(-1);
349
+ }
350
+ function appleMessageDateToIso(value, label) {
351
+ const numeric = Number(value);
352
+ if (!Number.isFinite(numeric))
353
+ throw new Error(`Invalid ${label}: ${String(value)}`);
354
+ const unixMs = numeric > 10_000_000_000_000_000
355
+ ? numeric / 1_000_000 + 978_307_200_000
356
+ : numeric > 10_000_000_000
357
+ ? numeric + 978_307_200_000
358
+ : numeric * 1_000 + 978_307_200_000;
359
+ return new Date(unixMs).toISOString();
360
+ }
361
+ function integerFromSqlite(value) {
362
+ const numeric = Number(value ?? 0);
363
+ return Number.isInteger(numeric) && numeric >= 0 ? numeric : 0;
364
+ }
365
+ function normalizeService(service) {
366
+ return service === undefined || service.trim().length === 0 ? 'unknown' : service.trim();
367
+ }
368
+ function isoTimestamp(value, label) {
369
+ if (Number.isNaN(Date.parse(value)) || !value.includes('T'))
370
+ throw new Error(`Invalid ${label} timestamp: ${value}`);
371
+ return value;
372
+ }
373
+ function requireNonEmptyValue(value, label) {
374
+ if (value.trim().length === 0)
375
+ throw new Error(`${label} must be non-empty`);
376
+ return value;
377
+ }
378
+ function assertNonEmptyString(value, label) {
379
+ if (value.trim().length === 0)
380
+ throw new Error(`${label} must be non-empty`);
381
+ }
382
+ function hashText(value) {
383
+ return (0, crypto_1.createHash)('sha256').update(value).digest('hex');
384
+ }
385
+ function emptySchema() {
386
+ return { type: 'object', additionalProperties: false, properties: {} };
387
+ }
388
+ function setupSchema() {
389
+ return {
390
+ type: 'object',
391
+ additionalProperties: false,
392
+ required: ['watch'],
393
+ properties: {
394
+ watch: { type: 'string', minLength: 2 },
395
+ candidate: { type: 'string' },
396
+ alias: { type: 'string' },
397
+ approve: { type: 'boolean' },
398
+ },
399
+ };
400
+ }
401
+ function contextSchema() {
402
+ return {
403
+ type: 'object',
404
+ additionalProperties: false,
405
+ required: ['conversationRef'],
406
+ properties: {
407
+ conversationRef: { type: 'string', minLength: 60 },
408
+ limit: { type: 'integer', minimum: 1, maximum: 20 },
409
+ },
410
+ };
411
+ }
412
+ function success(value) {
413
+ return { content: [{ type: 'text', text: JSON.stringify(value) }] };
414
+ }
415
+ function failure(code, message) {
416
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ code, message }) }] };
417
+ }
418
+ function isRecord(value) {
419
+ return typeof value === 'object' && value !== null;
420
+ }
421
+ function parseRequest(line) {
422
+ try {
423
+ const parsed = JSON.parse(line);
424
+ return isRecord(parsed) ? parsed : undefined;
425
+ }
426
+ catch {
427
+ return undefined;
428
+ }
429
+ }
430
+ function write(stdout, id, result) {
431
+ stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
432
+ }
@@ -0,0 +1,145 @@
1
+ import Contacts
2
+ import CryptoKit
3
+ import Foundation
4
+
5
+ let outputFilePath: String? = {
6
+ let arguments = CommandLine.arguments
7
+ guard let index = arguments.firstIndex(of: "--output-file"), index + 1 < arguments.count else {
8
+ return nil
9
+ }
10
+ return arguments[index + 1]
11
+ }()
12
+
13
+ func printJson(_ value: Any) {
14
+ let data = try! JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
15
+ if let outputFilePath {
16
+ FileManager.default.createFile(atPath: outputFilePath, contents: data)
17
+ } else {
18
+ print(String(data: data, encoding: .utf8)!)
19
+ }
20
+ }
21
+
22
+ func readRequest() -> [String: Any] {
23
+ let arguments = CommandLine.arguments
24
+ if let inputIndex = arguments.firstIndex(of: "--input-file"), inputIndex + 1 < arguments.count,
25
+ let data = FileManager.default.contents(atPath: arguments[inputIndex + 1]),
26
+ let object = try? JSONSerialization.jsonObject(with: data), let request = object as? [String: Any] {
27
+ return request
28
+ }
29
+ let data = FileHandle.standardInput.readDataToEndOfFile()
30
+ return ((try? JSONSerialization.jsonObject(with: data)) as? [String: Any]) ?? [:]
31
+ }
32
+
33
+ func opaqueConversationRef(_ guid: String) -> String {
34
+ let digest = SHA256.hash(data: Data(guid.utf8))
35
+ return "apple_messages_conversation_" + digest.map { String(format: "%02x", $0) }.joined()
36
+ }
37
+
38
+ func databasePath(_ request: [String: Any]) -> String {
39
+ return ((request["databasePath"] as? String) ?? (NSHomeDirectory() as NSString).appendingPathComponent("Library/Messages/chat.db"))
40
+ .trimmingCharacters(in: .whitespacesAndNewlines)
41
+ }
42
+
43
+ func sqlQuote(_ value: String) -> String { return "'\(value.replacingOccurrences(of: "'", with: "''"))'" }
44
+
45
+ func sqlite(_ path: String, _ sql: String) throws -> (stdout: String, stderr: String, status: Int32) {
46
+ let process = Process()
47
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
48
+ process.arguments = ["-readonly", "-json", path, sql]
49
+ let stdout = Pipe(); let stderr = Pipe()
50
+ process.standardOutput = stdout; process.standardError = stderr
51
+ try process.run(); process.waitUntilExit()
52
+ return (String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", process.terminationStatus)
53
+ }
54
+
55
+ func failureStatus(_ stderr: String) -> String {
56
+ let lower = stderr.lowercased()
57
+ if lower.contains("authorization denied") || lower.contains("not authorized") || lower.contains("permission denied") || lower.contains("operation not permitted") || lower.contains("unable to open database") { return "missing_permission" }
58
+ if lower.contains("command not found") || lower.contains("no such file") { return "unsupported" }
59
+ return "failed"
60
+ }
61
+
62
+ func failureFinding(_ status: String) -> String {
63
+ if status == "missing_permission" { return "Apple Messages chat.db is not readable; grant Full Disk Access to the configured Apple Messages host app." }
64
+ if status == "unsupported" { return "Apple Messages live reads require the sqlite3 command on this Mac." }
65
+ return "Apple Messages host helper failed before producing normalized records."
66
+ }
67
+
68
+ func result(_ kind: String, status: String, rows: [[String: Any]] = [], candidates: [[String: Any]] = [], findings: [String] = []) {
69
+ var body: [String: Any] = ["schemaVersion": 1, "kind": kind, "status": status, "findings": findings]
70
+ if kind.contains("candidates") { body["candidates"] = candidates } else { body["rows"] = rows }
71
+ printJson(body)
72
+ }
73
+
74
+ func contactIdentifiers(_ query: String) -> [String] {
75
+ guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else { return [] }
76
+ let keys: [CNKeyDescriptor] = [CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactNicknameKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactEmailAddressesKey as CNKeyDescriptor]
77
+ let request = CNContactFetchRequest(keysToFetch: keys)
78
+ var identifiers = Set<String>(); let needle = query.lowercased()
79
+ try? CNContactStore().enumerateContacts(with: request) { contact, _ in
80
+ let names = [contact.givenName, contact.familyName, contact.nickname, "\(contact.givenName) \(contact.familyName)"]
81
+ guard names.contains(where: { $0.lowercased().contains(needle) }) else { return }
82
+ contact.phoneNumbers.forEach { identifiers.insert($0.value.stringValue) }
83
+ contact.emailAddresses.forEach { identifiers.insert(String($0.value)) }
84
+ }
85
+ return Array(identifiers).sorted().prefix(10).map { $0 }
86
+ }
87
+
88
+ 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));"
93
+ }
94
+
95
+ func readSql(_ conversation: String, _ maxMessages: Int) -> String {
96
+ let value = sqlQuote(conversation)
97
+ return "SELECT m.ROWID AS messageId, c.guid AS chatGuid, m.is_from_me AS isFromMe, m.service AS service, m.date AS appleDate, h.id AS handleValue, m.text AS text, COALESCE(m.cache_has_attachments, 0) AS attachmentCount, COALESCE(m.date_edited, 0) AS dateEdited, COALESCE(m.date_retracted, 0) AS dateDeleted FROM chat c JOIN chat_message_join cmj ON cmj.chat_id = c.ROWID JOIN message m ON m.ROWID = cmj.message_id LEFT JOIN handle h ON h.ROWID = m.handle_id WHERE c.display_name = \(value) OR c.chat_identifier = \(value) OR c.guid = \(value) ORDER BY m.date DESC, m.ROWID DESC LIMIT \(min(max(maxMessages, 1), 500));"
98
+ }
99
+
100
+ let command = CommandLine.arguments.dropFirst().first(where: { !$0.hasPrefix("--") }) ?? "messages-read"
101
+ let request = readRequest(); let path = databasePath(request)
102
+ let candidateKind = "apple-messages-helper-candidates-result"; let readKind = "apple-messages-helper-read-result"
103
+ guard !path.isEmpty && FileManager.default.fileExists(atPath: path) else { result(command == "messages-read" ? readKind : candidateKind, status: "failed", findings: ["Apple Messages chat.db was not found at the expected local path."]); exit(2) }
104
+ guard FileManager.default.isReadableFile(atPath: path) else { result(command == "messages-read" ? readKind : candidateKind, status: "missing_permission", findings: ["Apple Messages chat.db is not readable; grant Full Disk Access to the configured Apple Messages host app."]); exit(3) }
105
+
106
+ do {
107
+ if command == "messages-candidates" {
108
+ let query = ((request["query"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
109
+ 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))
111
+ guard response.status == 0 else { let status = failureStatus(response.stderr); result(candidateKind, status: status, findings: [failureFinding(status)]); exit(status == "missing_permission" ? 3 : 2) }
112
+ let rows = ((try? JSONSerialization.jsonObject(with: Data(response.stdout.utf8))) as? [[String: Any]]) ?? []
113
+ 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"] }
114
+ result(candidateKind, status: "ready", candidates: candidates)
115
+ } else if command == "messages-validate" {
116
+ let ref = (request["conversationRef"] as? String) ?? ""
117
+ let response = try sqlite(path, "SELECT c.guid AS chatGuid FROM chat c ORDER BY c.ROWID DESC;")
118
+ guard response.status == 0 else { let status = failureStatus(response.stderr); result(candidateKind, status: status, findings: [failureFinding(status)]); exit(status == "missing_permission" ? 3 : 2) }
119
+ let rows = ((try? JSONSerialization.jsonObject(with: Data(response.stdout.utf8))) as? [[String: Any]]) ?? []
120
+ let found = rows.contains { ($0["chatGuid"] as? String).map(opaqueConversationRef) == ref }
121
+ result(candidateKind, status: found ? "ready" : "missing_config", findings: found ? [] : ["The configured Apple Messages conversation is no longer available."])
122
+ } else {
123
+ var conversation = ((request["conversationLabel"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
124
+ let ref = ((request["conversationRef"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
125
+ if !ref.isEmpty {
126
+ let response = try sqlite(path, "SELECT c.guid AS chatGuid FROM chat c ORDER BY c.ROWID DESC;")
127
+ guard response.status == 0 else {
128
+ let status = failureStatus(response.stderr)
129
+ result(readKind, status: status, findings: [failureFinding(status)])
130
+ exit(status == "missing_permission" ? 3 : 2)
131
+ }
132
+ let rows = ((try? JSONSerialization.jsonObject(with: Data(response.stdout.utf8))) as? [[String: Any]]) ?? []
133
+ guard let guid = rows.first(where: { ($0["chatGuid"] as? String).map(opaqueConversationRef) == ref })?["chatGuid"] as? String else {
134
+ result(readKind, status: "missing_config", findings: ["The configured Apple Messages conversation is no longer available."])
135
+ exit(2)
136
+ }
137
+ conversation = guid
138
+ }
139
+ guard !conversation.isEmpty else { result(readKind, status: "missing_config", findings: ["Apple Messages host helper requires a watched conversation label."]); exit(64) }
140
+ let response = try sqlite(path, readSql(conversation, (request["maxMessages"] as? Int) ?? 50))
141
+ guard response.status == 0 else { let status = failureStatus(response.stderr); result(readKind, status: status, findings: [failureFinding(status)]); exit(status == "missing_permission" ? 3 : 2) }
142
+ let rows = response.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : (((try? JSONSerialization.jsonObject(with: Data(response.stdout.utf8))) as? [[String: Any]]) ?? [])
143
+ result(readKind, status: "ready", rows: rows)
144
+ }
145
+ } catch { result(command == "messages-read" ? readKind : candidateKind, status: "unsupported", findings: ["Apple Messages live reads require the sqlite3 command on this Mac."]); exit(2) }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@motionsmith/apple-messages-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Library-first MCP protocol and local macOS helper toolkit for Apple Messages.",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "helper/apple-messages-helper.swift",
17
+ "scripts/build-apple-messages-helper.sh",
18
+ "scripts/apple-messages-helper.Info.plist",
19
+ "README.md",
20
+ "LICENSE.md",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/motionsmith/apple-messages-mcp.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/motionsmith/apple-messages-mcp/issues"
32
+ },
33
+ "homepage": "https://github.com/motionsmith/apple-messages-mcp#readme",
34
+ "devDependencies": {
35
+ "@eslint/js": "^9.39.1",
36
+ "@types/node": "^22.15.0",
37
+ "eslint": "^9.39.1",
38
+ "globals": "^16.5.0",
39
+ "typescript": "^5.8.3",
40
+ "typescript-eslint": "^8.59.4",
41
+ "vitest": "^4.1.6"
42
+ },
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
+ }
@@ -0,0 +1,20 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>en</string>
7
+ <key>CFBundleExecutable</key>
8
+ <string>apple-messages-helper</string>
9
+ <key>CFBundleIdentifier</key>
10
+ <string>com.motionsmith.apple-messages-mcp.host</string>
11
+ <key>CFBundleName</key>
12
+ <string>Apple Messages MCP Host</string>
13
+ <key>CFBundlePackageType</key>
14
+ <string>APPL</string>
15
+ <key>CFBundleShortVersionString</key>
16
+ <string>0.1.0</string>
17
+ <key>LSUIElement</key>
18
+ <true/>
19
+ </dict>
20
+ </plist>
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ SOURCE="$ROOT/helper/apple-messages-helper.swift"
6
+ APP_NAME="${APPLE_MESSAGES_MCP_HOST_APP_NAME:-Apple Messages MCP Host}"
7
+ BUNDLE_ID="${APPLE_MESSAGES_MCP_HOST_BUNDLE_ID:-com.motionsmith.apple-messages-mcp.host}"
8
+ APP_DIR="${APPLE_MESSAGES_MCP_HOST_APP_DIR:-$ROOT/bin/$APP_NAME.app}"
9
+ EXECUTABLE="$APP_DIR/Contents/MacOS/apple-messages-helper"
10
+ PLIST="$APP_DIR/Contents/Info.plist"
11
+ TEMPLATE="$ROOT/scripts/apple-messages-helper.Info.plist"
12
+
13
+ if [ "$(uname -s)" != "Darwin" ]; then
14
+ echo 'Apple Messages helper compilation requires macOS.' >&2
15
+ exit 1
16
+ fi
17
+
18
+ mkdir -p "$(dirname "$EXECUTABLE")"
19
+ swiftc -framework Contacts "$SOURCE" -o "$EXECUTABLE"
20
+ chmod +x "$EXECUTABLE"
21
+ sed \
22
+ -e "s/com.motionsmith.apple-messages-mcp.host/$BUNDLE_ID/g" \
23
+ -e "s/Apple Messages MCP Host/$APP_NAME/g" \
24
+ "$TEMPLATE" > "$PLIST"
25
+ codesign --force --sign "${APPLE_MESSAGES_MCP_CODESIGN_IDENTITY:--}" "$EXECUTABLE"
26
+ codesign --force --sign "${APPLE_MESSAGES_MCP_CODESIGN_IDENTITY:--}" "$APP_DIR"
27
+ printf 'Built %s\n' "$APP_DIR"