imessage-mcp-server 1.0.0 → 2.0.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/src/safety.js ADDED
@@ -0,0 +1,58 @@
1
+ import { LOG } from "./shared/utils.js";
2
+
3
+ export function isToolAllowed(toolName, safety) {
4
+ if (safety.readOnly && isWriteTool(toolName)) {
5
+ return false;
6
+ }
7
+ if (safety.allowedTools && !safety.allowedTools.includes(toolName)) {
8
+ return false;
9
+ }
10
+ if (safety.blockedTools && safety.blockedTools.includes(toolName)) {
11
+ return false;
12
+ }
13
+ return true;
14
+ }
15
+
16
+ function isWriteTool(toolName) {
17
+ const writeTools = new Set([
18
+ "run_shell_command",
19
+ "shell",
20
+ "execute_command",
21
+ "write_file",
22
+ "edit_file",
23
+ "delete_file",
24
+ ]);
25
+ return writeTools.has(toolName);
26
+ }
27
+
28
+ export function isCommandBlocked(command, blockedPatterns) {
29
+ if (!command || !blockedPatterns || blockedPatterns.length === 0) return false;
30
+ for (const pattern of blockedPatterns) {
31
+ const regex = new RegExp(pattern, "i");
32
+ if (regex.test(command)) {
33
+ LOG.warn("Blocked command matched", { command, pattern });
34
+ return true;
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+
40
+ export async function requestConfirmation(text, replyFn) {
41
+ try {
42
+ await replyFn(`⚠️ 请求确认:\n${text}\n\n回复 "确认" 以继续执行。`);
43
+ // In a real implementation, this would wait for the user's reply.
44
+ // For now, we return false to be safe; the daemon can implement polling.
45
+ return false;
46
+ } catch (err) {
47
+ LOG.error("Failed to send confirmation request", { error: err.message });
48
+ return false;
49
+ }
50
+ }
51
+
52
+ export function sanitizeForLogging(input) {
53
+ const clone = { ...input };
54
+ if (clone.apiKey) clone.apiKey = "***";
55
+ if (clone.password) clone.password = "***";
56
+ if (clone.token) clone.token = "***";
57
+ return clone;
58
+ }
@@ -0,0 +1,45 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+ import { DEFAULTS } from "../shared/constants.js";
8
+ import { TOOLS } from "./tools.js";
9
+
10
+ export async function startServer() {
11
+ const server = new Server(
12
+ {
13
+ name: DEFAULTS.SERVER.name,
14
+ version: DEFAULTS.SERVER.version,
15
+ },
16
+ {
17
+ capabilities: {
18
+ tools: {},
19
+ },
20
+ }
21
+ );
22
+
23
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
24
+ return {
25
+ tools: Object.entries(TOOLS).map(([name, tool]) => ({
26
+ name,
27
+ description: tool.description,
28
+ inputSchema: tool.inputSchema,
29
+ })),
30
+ };
31
+ });
32
+
33
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
34
+ const { name, arguments: args } = request.params;
35
+ const tool = TOOLS[name];
36
+ if (!tool) {
37
+ throw new Error(`Unknown tool: ${name}`);
38
+ }
39
+ return tool.handler(args ?? {});
40
+ });
41
+
42
+ const transport = new StdioServerTransport();
43
+ await server.connect(transport);
44
+ console.error("iMessage MCP Server running on stdio");
45
+ }
@@ -0,0 +1,163 @@
1
+ import {
2
+ sendMessage,
3
+ listConversations,
4
+ readConversation,
5
+ getNewMessages,
6
+ } from "../shared/imessage.js";
7
+
8
+ export const TOOLS = {
9
+ send_imessage: {
10
+ description: "Send an iMessage to a recipient's email or phone number",
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: {
14
+ recipient: {
15
+ type: "string",
16
+ description: "Recipient iMessage account (email or phone number)",
17
+ },
18
+ text: {
19
+ type: "string",
20
+ description: "Message text to send",
21
+ },
22
+ },
23
+ required: ["recipient", "text"],
24
+ },
25
+ handler: (args) => {
26
+ const result = sendMessage(args.recipient, args.text);
27
+ return {
28
+ content: [{ type: "text", text: JSON.stringify(result) }],
29
+ };
30
+ },
31
+ },
32
+
33
+ list_conversations: {
34
+ description:
35
+ "List recent iMessage conversations with their latest message preview and unread count",
36
+ inputSchema: {
37
+ type: "object",
38
+ properties: {
39
+ limit: {
40
+ type: "number",
41
+ description: "Max conversations to return (default 20)",
42
+ default: 20,
43
+ },
44
+ },
45
+ },
46
+ handler: (args) => {
47
+ const conversations = listConversations(args.limit ?? 20);
48
+ return {
49
+ content: [
50
+ {
51
+ type: "text",
52
+ text: JSON.stringify(conversations, null, 2),
53
+ },
54
+ ],
55
+ };
56
+ },
57
+ },
58
+
59
+ read_conversation: {
60
+ description:
61
+ "Read messages from a specific conversation by chat_id or handle (email/phone). Returns paginated messages.",
62
+ inputSchema: {
63
+ type: "object",
64
+ properties: {
65
+ handle: {
66
+ type: "string",
67
+ description:
68
+ "Filter by handle (email or phone number of the conversation partner)",
69
+ },
70
+ chat_id: {
71
+ type: "number",
72
+ description: "Filter by chat ROWID",
73
+ },
74
+ limit: {
75
+ type: "number",
76
+ description: "Max messages to return (default 30)",
77
+ default: 30,
78
+ },
79
+ before_id: {
80
+ type: "number",
81
+ description:
82
+ "Return messages before this ROWID (for pagination/older messages)",
83
+ },
84
+ include_read: {
85
+ type: "boolean",
86
+ description: "Include already-read messages (default true)",
87
+ default: true,
88
+ },
89
+ unread_only: {
90
+ type: "boolean",
91
+ description: "Only return unread messages (default false)",
92
+ default: false,
93
+ },
94
+ },
95
+ },
96
+ handler: (args) => {
97
+ const result = readConversation({
98
+ handle: args.handle,
99
+ chat_id: args.chat_id,
100
+ limit: args.limit ?? 30,
101
+ before_id: args.before_id,
102
+ include_read: args.include_read !== false,
103
+ unread_only: args.unread_only === true,
104
+ });
105
+ return {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: JSON.stringify(result, null, 2),
110
+ },
111
+ ],
112
+ };
113
+ },
114
+ },
115
+
116
+ get_new_messages: {
117
+ description:
118
+ "Get recently received messages since a given timestamp. Use this to poll for new incoming iMessages.",
119
+ inputSchema: {
120
+ type: "object",
121
+ properties: {
122
+ since: {
123
+ type: "string",
124
+ description:
125
+ "ISO timestamp to fetch messages from (e.g., '2026-06-28T10:00:00.000Z'). If omitted, returns last 10 messages.",
126
+ },
127
+ max_results: {
128
+ type: "number",
129
+ description: "Max messages to return (default 10)",
130
+ default: 10,
131
+ },
132
+ unread_only: {
133
+ type: "boolean",
134
+ description: "Only return unread messages (default false)",
135
+ default: false,
136
+ },
137
+ },
138
+ },
139
+ handler: (args) => {
140
+ const messages = getNewMessages({
141
+ since: args.since,
142
+ max_results: args.max_results ?? 10,
143
+ unread_only: args.unread_only === true,
144
+ });
145
+ return {
146
+ content: [
147
+ {
148
+ type: "text",
149
+ text: JSON.stringify(
150
+ {
151
+ total: messages.length,
152
+ has_unread: messages.some((m) => !m.is_read),
153
+ messages,
154
+ },
155
+ null,
156
+ 2
157
+ ),
158
+ },
159
+ ],
160
+ };
161
+ },
162
+ },
163
+ };
@@ -0,0 +1,30 @@
1
+ import { homedir } from "os";
2
+ import { resolve } from "path";
3
+
4
+ // Apple Cocoa epoch: 2001-01-01 00:00:00 UTC
5
+ export const COCOA_EPOCH_OFFSET_S = 978_307_200;
6
+
7
+ // Default iMessage SQLite database path
8
+ export const DEFAULT_DB_PATH = resolve(
9
+ homedir(),
10
+ "Library/Messages/chat.db"
11
+ );
12
+
13
+ // Runtime path override via environment
14
+ export const DB_PATH = resolve(
15
+ process.env.IMESSAGE_DB_PATH || DEFAULT_DB_PATH
16
+ );
17
+
18
+ export const DEFAULTS = {
19
+ SERVER: {
20
+ name: "imessage-mcp",
21
+ version: "2.0.0",
22
+ },
23
+ BRIDGE: {
24
+ pollIntervalMs: 3000,
25
+ maxHistoryPerConversation: 20,
26
+ maxToolIterations: 10,
27
+ maxTokens: 4096,
28
+ sendProcessingIndicator: true,
29
+ },
30
+ };
@@ -0,0 +1,277 @@
1
+ import Database from "better-sqlite3";
2
+ import { execSync } from "child_process";
3
+ import { existsSync } from "fs";
4
+ import { COCOA_EPOCH_OFFSET_S, DB_PATH } from "./constants.js";
5
+ import { cocoaDateToISO } from "./utils.js";
6
+
7
+ export function validateDbPath() {
8
+ if (!existsSync(DB_PATH)) {
9
+ console.error("❌ iMessage database not found at: " + DB_PATH);
10
+ console.error(" Make sure you are signed into iMessage on this Mac.");
11
+ console.error(
12
+ " If the database is elsewhere, set IMESSAGE_DB_PATH to its location."
13
+ );
14
+ process.exit(1);
15
+ }
16
+ }
17
+
18
+ export function openDb(options = {}) {
19
+ validateDbPath();
20
+ const db = new Database(DB_PATH, {
21
+ readonly: options.readonly !== false,
22
+ fileMustExist: true,
23
+ });
24
+ db.pragma("journal_mode = WAL");
25
+ if (options.queryOnly) {
26
+ db.pragma("query_only = true");
27
+ }
28
+ return db;
29
+ }
30
+
31
+ export function sendMessage(recipient, text) {
32
+ const escaped = text.replace(/"/g, '\\"').replace(/\n/g, "\\n");
33
+ const script = `tell application "Messages" to send "${escaped}" to buddy "${recipient}"`;
34
+ execSync(`osascript -e '${script}'`, {
35
+ encoding: "utf-8",
36
+ timeout: 15_000,
37
+ });
38
+ return { success: true, recipient, text };
39
+ }
40
+
41
+ export function listConversations(limit = 20) {
42
+ const db = openDb();
43
+ const rows = db
44
+ .prepare(
45
+ `
46
+ SELECT
47
+ c.ROWID AS chat_id,
48
+ c.display_name,
49
+ c.chat_identifier,
50
+ c.service_name,
51
+ h.id AS handle_id_str,
52
+ m.ROWID AS last_msg_id,
53
+ m.text AS last_msg_text,
54
+ m.date AS last_msg_date,
55
+ m.is_from_me AS last_msg_from_me,
56
+ (SELECT COUNT(*) FROM message
57
+ WHERE handle_id = h.ROWID
58
+ AND is_read = 0 AND is_from_me = 0
59
+ AND is_finished = 1
60
+ ) AS unread_count
61
+ FROM chat c
62
+ JOIN chat_handle_join chj ON chj.chat_id = c.ROWID
63
+ JOIN handle h ON h.ROWID = chj.handle_id
64
+ LEFT JOIN (
65
+ SELECT cmj.chat_id, MAX(m.ROWID) AS max_msg_id
66
+ FROM chat_message_join cmj
67
+ JOIN message m ON m.ROWID = cmj.message_id
68
+ GROUP BY cmj.chat_id
69
+ ) latest ON latest.chat_id = c.ROWID
70
+ LEFT JOIN message m ON m.ROWID = latest.max_msg_id
71
+ ORDER BY COALESCE(m.date, 0) DESC
72
+ LIMIT ?
73
+ `
74
+ )
75
+ .all(Math.min(limit, 100));
76
+ db.close();
77
+
78
+ return rows.map((r) => ({
79
+ chat_id: r.chat_id,
80
+ display_name: r.display_name || r.chat_identifier || r.handle_id_str,
81
+ handle: r.handle_id_str,
82
+ service: r.service_name,
83
+ unread_count: r.unread_count || 0,
84
+ last_message: r.last_msg_text
85
+ ? {
86
+ text: r.last_msg_text.substring(0, 200),
87
+ date: cocoaDateToISO(r.last_msg_date),
88
+ is_from_me: !!r.last_msg_from_me,
89
+ }
90
+ : null,
91
+ }));
92
+ }
93
+
94
+ export function readConversation({
95
+ handle,
96
+ chat_id,
97
+ limit = 30,
98
+ before_id,
99
+ include_read = true,
100
+ unread_only = false,
101
+ }) {
102
+ const db = openDb();
103
+ let where = "WHERE 1=1";
104
+ const params = [];
105
+
106
+ if (handle) {
107
+ where += " AND h.id = ?";
108
+ params.push(handle);
109
+ }
110
+ if (chat_id) {
111
+ where += " AND cmj.chat_id = ?";
112
+ params.push(chat_id);
113
+ }
114
+ if (before_id) {
115
+ where += " AND m.ROWID < ?";
116
+ params.push(before_id);
117
+ }
118
+ if (unread_only) {
119
+ where += " AND m.is_read = 0 AND m.is_from_me = 0 AND m.is_finished = 1";
120
+ } else if (!include_read) {
121
+ where += " AND m.is_read = 0 AND m.is_from_me = 0";
122
+ }
123
+
124
+ const rows = db
125
+ .prepare(
126
+ `
127
+ SELECT
128
+ m.ROWID,
129
+ m.text,
130
+ m.is_from_me,
131
+ m.is_read,
132
+ m.is_delivered,
133
+ m.date,
134
+ m.service,
135
+ m.date_read,
136
+ m.date_delivered,
137
+ h.id AS handle_id_str,
138
+ c.ROWID AS chat_id,
139
+ COALESCE(c.display_name, c.chat_identifier) AS chat_name
140
+ FROM message m
141
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
142
+ JOIN chat c ON c.ROWID = cmj.chat_id
143
+ LEFT JOIN handle h ON h.ROWID = m.handle_id
144
+ ${where}
145
+ ORDER BY m.date DESC
146
+ LIMIT ?
147
+ `
148
+ )
149
+ .all(...params, Math.min(limit, 200));
150
+ db.close();
151
+
152
+ const messages = rows
153
+ .map((r) => ({
154
+ id: r.ROWID,
155
+ chat_id: r.chat_id,
156
+ chat_name: r.chat_name,
157
+ text: r.text,
158
+ from_me: !!r.is_from_me,
159
+ from: r.is_from_me ? "me" : r.handle_id_str,
160
+ is_read: !!r.is_read,
161
+ is_delivered: !!r.is_delivered,
162
+ service: r.service,
163
+ date: cocoaDateToISO(r.date),
164
+ date_read: cocoaDateToISO(r.date_read),
165
+ date_delivered: cocoaDateToISO(r.date_delivered),
166
+ }))
167
+ .reverse();
168
+
169
+ return {
170
+ chat_id: rows[0]?.chat_id ?? null,
171
+ chat_name: rows[0]?.chat_name ?? null,
172
+ total: messages.length,
173
+ messages,
174
+ };
175
+ }
176
+
177
+ export function getNewMessages({ since, max_results = 10, unread_only = false }) {
178
+ const db = openDb();
179
+ let where = "WHERE m.is_from_me = 0 AND m.is_finished = 1";
180
+
181
+ if (since) {
182
+ const d = new Date(since);
183
+ if (!isNaN(d.getTime())) {
184
+ const cocoaNs = (d.getTime() / 1000 - COCOA_EPOCH_OFFSET_S) * 1_000_000_000;
185
+ where += ` AND m.date > ${Math.floor(cocoaNs)}`;
186
+ }
187
+ }
188
+
189
+ if (unread_only) {
190
+ where += " AND m.is_read = 0";
191
+ }
192
+
193
+ const rows = db
194
+ .prepare(
195
+ `
196
+ SELECT
197
+ m.ROWID,
198
+ m.text,
199
+ m.is_from_me,
200
+ m.is_read,
201
+ m.is_delivered,
202
+ m.date,
203
+ m.service,
204
+ h.id AS handle_id_str,
205
+ c.ROWID AS chat_id,
206
+ COALESCE(c.display_name, c.chat_identifier) AS chat_name
207
+ FROM message m
208
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
209
+ JOIN chat c ON c.ROWID = cmj.chat_id
210
+ LEFT JOIN handle h ON h.ROWID = m.handle_id
211
+ ${where}
212
+ ORDER BY m.date DESC
213
+ LIMIT ?
214
+ `
215
+ )
216
+ .all(Math.min(max_results, 100));
217
+ db.close();
218
+
219
+ return rows.map((r) => ({
220
+ id: r.ROWID,
221
+ chat_id: r.chat_id,
222
+ chat_name: r.chat_name,
223
+ text: r.text,
224
+ from: r.handle_id_str,
225
+ is_read: !!r.is_read,
226
+ is_delivered: !!r.is_delivered,
227
+ service: r.service,
228
+ date: cocoaDateToISO(r.date),
229
+ }));
230
+ }
231
+
232
+ // ─── Bridge-specific helpers ────────────────────────────────────────────────
233
+
234
+ export function getMasterHandleId(db, masterHandle) {
235
+ const row = db.prepare("SELECT ROWID FROM handle WHERE id = ?").get(masterHandle);
236
+ return row?.ROWID;
237
+ }
238
+
239
+ export function getLatestMessageId(db) {
240
+ const row = db.prepare("SELECT MAX(ROWID) as max_id FROM message").get();
241
+ return row?.max_id || 0;
242
+ }
243
+
244
+ export function getChatName(db, chatId) {
245
+ const row = db
246
+ .prepare(
247
+ `
248
+ SELECT COALESCE(c.display_name, c.chat_identifier, h.id) AS name
249
+ FROM chat c
250
+ LEFT JOIN chat_handle_join chj ON chj.chat_id = c.ROWID
251
+ LEFT JOIN handle h ON h.ROWID = chj.handle_id
252
+ WHERE c.ROWID = ?
253
+ LIMIT 1
254
+ `
255
+ )
256
+ .get(chatId);
257
+ return row?.name || `chat_${chatId}`;
258
+ }
259
+
260
+ export function getNewMessagesForBridge(db, handleId, lastId) {
261
+ return db
262
+ .prepare(
263
+ `
264
+ SELECT m.ROWID, m.text, m.date, cmj.chat_id
265
+ FROM message m
266
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
267
+ WHERE m.is_from_me = 0
268
+ AND m.handle_id = ?
269
+ AND m.ROWID > ?
270
+ AND m.is_finished = 1
271
+ AND m.text IS NOT NULL
272
+ AND m.text != ''
273
+ ORDER BY m.ROWID ASC
274
+ `
275
+ )
276
+ .all(handleId, lastId || 0);
277
+ }
@@ -0,0 +1,54 @@
1
+ import { COCOA_EPOCH_OFFSET_S } from "./constants.js";
2
+
3
+ export function cocoaDateToISO(cocoaNs) {
4
+ if (!cocoaNs || cocoaNs <= 0) return null;
5
+ const unixMs = (cocoaNs / 1_000_000_000 + COCOA_EPOCH_OFFSET_S) * 1000;
6
+ return new Date(unixMs).toISOString();
7
+ }
8
+
9
+ export function isoToCocoaNs(isoString) {
10
+ const d = new Date(isoString);
11
+ if (isNaN(d.getTime())) return null;
12
+ return Math.floor((d.getTime() / 1000 - COCOA_EPOCH_OFFSET_S) * 1_000_000_000);
13
+ }
14
+
15
+ export function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+
19
+ export function log(level, msg, data = null) {
20
+ const ts = new Date().toISOString();
21
+ const line = data
22
+ ? `[${ts}] [${level}] ${msg} ${JSON.stringify(data)}`
23
+ : `[${ts}] [${level}] ${msg}`;
24
+ console.error(line);
25
+ }
26
+
27
+ export const LOG = {
28
+ info: (m, d) => log("INFO", m, d),
29
+ warn: (m, d) => log("WARN", m, d),
30
+ error: (m, d) => log("ERROR", m, d),
31
+ debug: (m, d) => log("DEBUG", m, d),
32
+ };
33
+
34
+ export function truncate(str, maxLen = 5000, suffix = "\n\n... (truncated)") {
35
+ if (!str || str.length <= maxLen) return str;
36
+ return str.substring(0, maxLen) + suffix;
37
+ }
38
+
39
+ export function splitLongReply(text, maxPartLen = 300) {
40
+ if (text.length <= maxPartLen) return [text];
41
+ const sentences = text.split(/(?<=[。!?\n])/);
42
+ const parts = [];
43
+ let current = "";
44
+ for (const s of sentences) {
45
+ if (current.length + s.length > maxPartLen && current.length > 0) {
46
+ parts.push(current.trim());
47
+ current = s;
48
+ } else {
49
+ current += s;
50
+ }
51
+ }
52
+ if (current.trim()) parts.push(current.trim());
53
+ return parts.length ? parts : [text.substring(0, maxPartLen)];
54
+ }