@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 +9 -0
- package/LICENSE.md +21 -0
- package/README.md +115 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.js +432 -0
- package/helper/apple-messages-helper.swift +145 -0
- package/package.json +50 -0
- package/scripts/apple-messages-helper.Info.plist +20 -0
- package/scripts/build-apple-messages-helper.sh +27 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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"
|