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/package.json CHANGED
@@ -1,34 +1,42 @@
1
1
  {
2
2
  "name": "imessage-mcp-server",
3
- "version": "1.0.0",
4
- "description": "MCP server for reading and sending iMessages on macOS",
3
+ "version": "2.0.0",
4
+ "description": "MCP server and AI bridge for iMessage on macOS",
5
5
  "type": "module",
6
- "main": "index.js",
7
6
  "bin": {
8
- "imessage-mcp-server": "./index.js"
7
+ "imessage-mcp-server": "./bin/imessage-mcp-server"
9
8
  },
10
9
  "publishConfig": {
11
10
  "access": "public"
12
11
  },
13
12
  "files": [
14
- "index.js",
13
+ "bin/",
14
+ "src/",
15
+ "scripts/",
15
16
  "README.md",
17
+ "README.zh.md",
16
18
  "LICENSE"
17
19
  ],
18
20
  "preferUnplugged": true,
19
21
  "scripts": {
20
- "start": "node index.js",
22
+ "start": "node bin/imessage-mcp-server --server",
23
+ "bridge": "node bin/imessage-mcp-server --bridge",
21
24
  "test": "echo \"No tests yet\" && exit 0"
22
25
  },
23
26
  "keywords": [
24
27
  "imessage",
28
+ "imessage-mcp",
29
+ "imessage-mcp-server",
25
30
  "mcp",
26
31
  "mcp-server",
27
32
  "model-context-protocol",
28
33
  "macos",
29
34
  "messages",
30
35
  "icloud",
31
- "apple"
36
+ "apple",
37
+ "ai",
38
+ "bridge",
39
+ "agent"
32
40
  ],
33
41
  "author": "tinyxia",
34
42
  "license": "MIT",
@@ -44,7 +52,9 @@
44
52
  "url": "https://github.com/tinyxia/imessage-mcp/issues"
45
53
  },
46
54
  "dependencies": {
55
+ "@anthropic-ai/sdk": "^0.30.0",
47
56
  "@modelcontextprotocol/sdk": "^1.29.0",
48
- "better-sqlite3": "^12.11.1"
57
+ "better-sqlite3": "^12.11.1",
58
+ "openai": "^4.0.0"
49
59
  }
50
60
  }
@@ -0,0 +1,98 @@
1
+ #!/bin/bash
2
+ # Install iMessage Bridge as macOS LaunchAgent (auto-start on login)
3
+ #
4
+ # Usage:
5
+ # install-launchagent.sh [load|unload|status] [config-path]
6
+
7
+ set -e
8
+
9
+ PLIST="com.inddaily.imessage-mcp-server.bridge.plist"
10
+ LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents"
11
+ DEST="$LAUNCH_AGENTS_DIR/$PLIST"
12
+ LOG_DIR="$HOME/Library/Logs"
13
+ CONFIG_PATH="${2:-}"
14
+
15
+ # Determine how to run the bridge.
16
+ # Prefer npx if available, otherwise fall back to node with this package path.
17
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18
+ PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
19
+
20
+ if command -v npx >/dev/null 2>&1; then
21
+ RUN_COMMAND="npx"
22
+ RUN_ARGS="-y imessage-mcp-server"
23
+ else
24
+ RUN_COMMAND="node"
25
+ RUN_ARGS="$PACKAGE_DIR/bin/imessage-mcp-server"
26
+ fi
27
+
28
+ case "${1:-load}" in
29
+ load)
30
+ echo "📦 Installing iMessage Bridge LaunchAgent..."
31
+ mkdir -p "$LAUNCH_AGENTS_DIR"
32
+ mkdir -p "$LOG_DIR"
33
+
34
+ cat > "$DEST" <<EOF
35
+ <?xml version="1.0" encoding="UTF-8"?>
36
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37
+ <plist version="1.0">
38
+ <dict>
39
+ <key>Label</key>
40
+ <string>com.inddaily.imessage-mcp-server.bridge</string>
41
+ <key>ProgramArguments</key>
42
+ <array>
43
+ <string>$RUN_COMMAND</string>
44
+ EOF
45
+
46
+ for arg in $RUN_ARGS; do
47
+ echo " <string>$arg</string>" >> "$DEST"
48
+ done
49
+
50
+ echo " <string>--bridge</string>" >> "$DEST"
51
+ if [ -n "$CONFIG_PATH" ]; then
52
+ echo " <string>--config</string>" >> "$DEST"
53
+ echo " <string>$CONFIG_PATH</string>" >> "$DEST"
54
+ fi
55
+
56
+ cat >> "$DEST" <<EOF
57
+ </array>
58
+ <key>RunAtLoad</key>
59
+ <true/>
60
+ <key>KeepAlive</key>
61
+ <true/>
62
+ <key>StandardOutPath</key>
63
+ <string>$LOG_DIR/imessage-mcp-server-bridge.log</string>
64
+ <key>StandardErrorPath</key>
65
+ <string>$LOG_DIR/imessage-mcp-server-bridge.log</string>
66
+ <key>EnvironmentVariables</key>
67
+ <dict>
68
+ <key>PATH</key>
69
+ <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin</string>
70
+ </dict>
71
+ </dict>
72
+ </plist>
73
+ EOF
74
+
75
+ launchctl load "$DEST" || launchctl bootstrap gui/"$(id -u)" "$DEST"
76
+ echo "✅ Installed and started!"
77
+ echo " Log: $LOG_DIR/imessage-mcp-server-bridge.log"
78
+ echo " Stop: launchctl unload $DEST"
79
+ ;;
80
+ unload)
81
+ echo "🗑️ Uninstalling iMessage Bridge..."
82
+ launchctl unload "$DEST" 2>/dev/null || true
83
+ rm -f "$DEST"
84
+ echo "✅ Uninstalled"
85
+ ;;
86
+ status)
87
+ if launchctl list | grep -q "com.inddaily.imessage-mcp-server.bridge"; then
88
+ echo "🟢 Running"
89
+ launchctl list | grep com.inddaily.imessage-mcp-server.bridge
90
+ else
91
+ echo "🔴 Not running"
92
+ fi
93
+ ;;
94
+ *)
95
+ echo "Usage: $0 [load|unload|status] [config-path]"
96
+ exit 1
97
+ ;;
98
+ esac
@@ -0,0 +1,243 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { homedir } from "os";
4
+ import {
5
+ openDb,
6
+ getMasterHandleId,
7
+ getNewMessagesForBridge,
8
+ sendMessage,
9
+ } from "../shared/imessage.js";
10
+ import { LOG, sleep } from "../shared/utils.js";
11
+ import { LlmLoop } from "./llm-loop.js";
12
+
13
+ const STATE_DIR = path.join(homedir(), ".imessage-mcp-server");
14
+ const STATE_PATH = path.join(STATE_DIR, "bridge-state.json");
15
+
16
+ export class BridgeDaemon {
17
+ constructor(config, mcpManager) {
18
+ this.config = config;
19
+ this.mcpManager = mcpManager;
20
+ this.llmLoop = new LlmLoop(config, mcpManager);
21
+ this.state = this.loadState();
22
+ this.shutdownRequested = false;
23
+ this.processing = false;
24
+
25
+ process.on("SIGINT", () => this.requestShutdown("SIGINT"));
26
+ process.on("SIGTERM", () => this.requestShutdown("SIGTERM"));
27
+ }
28
+
29
+ requestShutdown(signal) {
30
+ LOG.info(`Received ${signal}, shutting down...`);
31
+ this.shutdownRequested = true;
32
+ }
33
+
34
+ loadState() {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(STATE_PATH, "utf-8"));
37
+ } catch {
38
+ return {
39
+ lastProcessedId: {},
40
+ conversations: {},
41
+ initialized: false,
42
+ };
43
+ }
44
+ }
45
+
46
+ saveState() {
47
+ fs.mkdirSync(STATE_DIR, { recursive: true });
48
+ const cleaned = { ...this.state };
49
+ for (const [chatId, msgs] of Object.entries(cleaned.conversations || {})) {
50
+ const max = 50;
51
+ if (msgs.length > max) {
52
+ cleaned.conversations[chatId] = msgs.slice(msgs.length - max);
53
+ }
54
+ }
55
+ fs.writeFileSync(STATE_PATH, JSON.stringify(cleaned, null, 2));
56
+ }
57
+
58
+ async run() {
59
+ LOG.info("Starting iMessage Bridge Daemon", {
60
+ masterHandle: this.config.masterHandle,
61
+ provider: this.config.provider,
62
+ model: this.config.model,
63
+ });
64
+
65
+ await this.mcpManager.connectAll();
66
+
67
+ if (!this.state.initialized) {
68
+ await this.initializeState();
69
+ }
70
+
71
+ LOG.info("Entering polling loop...");
72
+
73
+ while (!this.shutdownRequested) {
74
+ try {
75
+ await this.pollCycle();
76
+ } catch (err) {
77
+ LOG.error("Poll cycle error", { error: err.message });
78
+ }
79
+ if (!this.shutdownRequested) {
80
+ await sleep(this.config.pollIntervalMs);
81
+ }
82
+ }
83
+
84
+ await this.mcpManager.close();
85
+ LOG.info("Daemon shut down gracefully");
86
+ }
87
+
88
+ async initializeState() {
89
+ const db = openDb();
90
+ try {
91
+ const handleId = getMasterHandleId(db, this.config.masterHandle);
92
+ if (!handleId) {
93
+ LOG.error("Master handle not found in chat.db", {
94
+ handle: this.config.masterHandle,
95
+ });
96
+ const handles = db.prepare("SELECT id FROM handle").all();
97
+ LOG.info("Available handles:");
98
+ for (const h of handles) LOG.info(` - ${h.id}`);
99
+ process.exit(1);
100
+ }
101
+
102
+ if (Object.keys(this.state.lastProcessedId).length === 0) {
103
+ const chats = db
104
+ .prepare(
105
+ `
106
+ SELECT DISTINCT cmj.chat_id
107
+ FROM message m
108
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
109
+ WHERE m.handle_id = ?
110
+ `
111
+ )
112
+ .all(handleId);
113
+ for (const c of chats) {
114
+ const lastMsg = db
115
+ .prepare(
116
+ `
117
+ SELECT MAX(m.ROWID) as last_id
118
+ FROM message m
119
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
120
+ WHERE m.handle_id = ? AND cmj.chat_id = ?
121
+ `
122
+ )
123
+ .get(handleId, c.chat_id);
124
+ if (lastMsg?.last_id) {
125
+ this.state.lastProcessedId[String(c.chat_id)] = lastMsg.last_id;
126
+ }
127
+ }
128
+ }
129
+
130
+ this.state.initialized = true;
131
+ this.saveState();
132
+ LOG.info("Initialized: skipping existing messages", {
133
+ chatsTracked: Object.keys(this.state.lastProcessedId).length,
134
+ });
135
+ } finally {
136
+ db.close();
137
+ }
138
+ }
139
+
140
+ async pollCycle() {
141
+ const db = openDb();
142
+ try {
143
+ const handleId = getMasterHandleId(db, this.config.masterHandle);
144
+ if (!handleId) {
145
+ LOG.warn("Master handle not found, skipping poll");
146
+ return;
147
+ }
148
+
149
+ const chatIds = Object.keys(this.state.lastProcessedId);
150
+ let newMessages = [];
151
+
152
+ if (chatIds.length === 0) {
153
+ // First run - discover chats
154
+ const chats = db
155
+ .prepare(
156
+ `
157
+ SELECT DISTINCT cmj.chat_id
158
+ FROM message m
159
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
160
+ WHERE m.handle_id = ?
161
+ `
162
+ )
163
+ .all(handleId);
164
+ for (const c of chats) {
165
+ const lastMsg = db
166
+ .prepare(
167
+ `
168
+ SELECT MAX(m.ROWID) as last_id
169
+ FROM message m
170
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
171
+ WHERE m.handle_id = ? AND cmj.chat_id = ?
172
+ `
173
+ )
174
+ .get(handleId, c.chat_id);
175
+ if (lastMsg?.last_id) {
176
+ this.state.lastProcessedId[String(c.chat_id)] = lastMsg.last_id;
177
+ }
178
+ }
179
+ this.saveState();
180
+ } else {
181
+ for (const chatId of chatIds) {
182
+ const lastId = this.state.lastProcessedId[chatId] || 0;
183
+ const msgs = getNewMessagesForBridge(db, handleId, lastId);
184
+ newMessages.push(...msgs);
185
+ }
186
+ }
187
+
188
+ if (newMessages.length > 0 && !this.processing) {
189
+ this.processing = true;
190
+ LOG.info(`Found ${newMessages.length} new message(s)`);
191
+
192
+ for (const msg of newMessages) {
193
+ if (this.shutdownRequested) break;
194
+ try {
195
+ await this.processMessage(msg);
196
+ this.saveState();
197
+ } catch (err) {
198
+ LOG.error("Error processing message", {
199
+ msgId: msg.ROWID,
200
+ error: err.message,
201
+ });
202
+ }
203
+ }
204
+
205
+ this.processing = false;
206
+ } else if (newMessages.length > 0 && this.processing) {
207
+ LOG.debug("Still processing previous message, skipping poll cycle");
208
+ }
209
+ } finally {
210
+ db.close();
211
+ }
212
+ }
213
+
214
+ async processMessage(message) {
215
+ const { ROWID: msgId, text: userText, chat_id: chatId } = message;
216
+ LOG.info(`Processing message #${msgId}`, {
217
+ chatId,
218
+ text: userText.substring(0, 100),
219
+ });
220
+
221
+ if (this.config.sendProcessingIndicator) {
222
+ await this.reply("⏳ 收到,正在处理...");
223
+ }
224
+
225
+ const replyFn = (text) => this.reply(text);
226
+ await this.llmLoop.process(userText, this.state, chatId, replyFn);
227
+
228
+ this.state.lastProcessedId[String(chatId)] = msgId;
229
+ LOG.info(`Message #${msgId} processed successfully`);
230
+ }
231
+
232
+ async reply(text) {
233
+ try {
234
+ sendMessage(this.config.masterHandle, text);
235
+ LOG.info("Reply sent", {
236
+ length: text.length,
237
+ preview: text.substring(0, 60),
238
+ });
239
+ } catch (err) {
240
+ LOG.error("Failed to send reply", { error: err.message });
241
+ }
242
+ }
243
+ }
@@ -0,0 +1,31 @@
1
+ import { validateBridgeConfig, buildBridgeConfig } from "../config.js";
2
+ import { McpClientManager } from "./mcp-client.js";
3
+ import { BridgeDaemon } from "./daemon.js";
4
+ import { LOG } from "../shared/utils.js";
5
+
6
+ export async function startBridge(args) {
7
+ const config = buildBridgeConfig(args);
8
+ validateBridgeConfig(config);
9
+
10
+ const mcpManager = new McpClientManager(config.mcpServers);
11
+ const daemon = new BridgeDaemon(config, mcpManager);
12
+
13
+ if (args.testConfig) {
14
+ LOG.info("Testing bridge configuration...");
15
+ await mcpManager.connectAll();
16
+ const tools = mcpManager.getAllTools();
17
+ LOG.info("Configuration valid", {
18
+ provider: config.provider,
19
+ model: config.model,
20
+ mcpServers: Object.keys(config.mcpServers).length,
21
+ toolsAvailable: tools.length,
22
+ });
23
+ for (const t of tools) {
24
+ LOG.info(` - ${t.name} (${t.serverId})`);
25
+ }
26
+ await mcpManager.close();
27
+ return;
28
+ }
29
+
30
+ await daemon.run();
31
+ }
@@ -0,0 +1,232 @@
1
+ import { sendMessage } from "../shared/imessage.js";
2
+ import { LOG, sleep, splitLongReply } from "../shared/utils.js";
3
+ import { isToolAllowed, isCommandBlocked } from "../safety.js";
4
+ import { createProvider } from "../providers/index.js";
5
+
6
+ export class LlmLoop {
7
+ constructor(config, mcpManager) {
8
+ this.config = config;
9
+ this.mcpManager = mcpManager;
10
+ this.provider = createProvider(config);
11
+ }
12
+
13
+ async process(userText, state, chatId, replyFn) {
14
+ const chatHistory = state.conversations[chatId] || [];
15
+ const apiMessages = [
16
+ ...chatHistory.slice(-(this.config.maxHistoryPerConversation || 20) * 2),
17
+ { role: "user", content: userText },
18
+ ];
19
+
20
+ const allMcpTools = this.mcpManager.getAllTools();
21
+ const localTools = this.getLocalTools();
22
+ const availableTools = [...allMcpTools, ...localTools]
23
+ .filter((t) => isToolAllowed(t.name, this.config.safety))
24
+ .map((t) => ({
25
+ name: t.name,
26
+ description: t.description,
27
+ inputSchema: t.inputSchema,
28
+ }));
29
+
30
+ let currentMessages = [...apiMessages];
31
+ let maxIterations = this.config.maxToolIterations || 10;
32
+ let finalText = "";
33
+ let usedLongReply = false;
34
+
35
+ try {
36
+ while (maxIterations-- > 0) {
37
+ LOG.debug("Calling LLM", {
38
+ messagesCount: currentMessages.length,
39
+ toolsCount: availableTools.length,
40
+ iterationsLeft: maxIterations,
41
+ });
42
+
43
+ const result = await this.provider.chat(currentMessages, availableTools);
44
+
45
+ const assistantContent = [];
46
+ for (const block of this.normalizeResultBlocks(result)) {
47
+ if (block.type === "text") {
48
+ finalText = block.text;
49
+ assistantContent.push(block);
50
+ } else if (block.type === "thinking") {
51
+ assistantContent.push(block);
52
+ } else if (block.type === "tool_use") {
53
+ assistantContent.push(block);
54
+ }
55
+ }
56
+
57
+ if (assistantContent.length > 0) {
58
+ currentMessages.push({ role: "assistant", content: assistantContent });
59
+ }
60
+
61
+ if (result.toolCalls.length === 0) break;
62
+
63
+ for (const toolCall of result.toolCalls) {
64
+ const toolResult = await this.executeTool(toolCall, replyFn);
65
+
66
+ currentMessages.push({
67
+ role: "user",
68
+ content: [
69
+ {
70
+ type: "tool_result",
71
+ tool_use_id: toolCall.id,
72
+ content:
73
+ typeof toolResult === "string"
74
+ ? toolResult
75
+ : JSON.stringify(toolResult),
76
+ },
77
+ ],
78
+ });
79
+
80
+ if (toolCall.name === "send_long_reply") {
81
+ usedLongReply = true;
82
+ }
83
+ }
84
+ }
85
+ } catch (err) {
86
+ LOG.error("LLM error", { error: err.message, status: err.status });
87
+ const errorMsg =
88
+ err.status === 401
89
+ ? "❌ API 认证失败,请检查 API Key 配置"
90
+ : err.status === 429
91
+ ? "❌ API 速率限制,请稍后再试"
92
+ : `❌ 处理出错: ${err.message}`;
93
+ await replyFn(errorMsg);
94
+ return;
95
+ }
96
+
97
+ // Send final response
98
+ if (finalText && !usedLongReply) {
99
+ await this.sendReply(finalText, replyFn);
100
+ }
101
+
102
+ // Update conversation history
103
+ state.conversations[chatId] = [
104
+ ...(state.conversations[chatId] || []).slice(
105
+ -(this.config.maxHistoryPerConversation || 20) * 2
106
+ ),
107
+ { role: "user", content: userText },
108
+ ];
109
+ if (finalText) {
110
+ state.conversations[chatId].push({
111
+ role: "assistant",
112
+ content: finalText,
113
+ });
114
+ }
115
+ }
116
+
117
+ normalizeResultBlocks(result) {
118
+ const blocks = [];
119
+ if (result.text) {
120
+ blocks.push({ type: "text", text: result.text });
121
+ }
122
+ for (const t of result.thinking || []) {
123
+ blocks.push({
124
+ type: "thinking",
125
+ thinking: t.thinking || "",
126
+ signature: t.signature,
127
+ });
128
+ }
129
+ for (const tc of result.toolCalls || []) {
130
+ blocks.push({
131
+ type: "tool_use",
132
+ id: tc.id,
133
+ name: tc.name,
134
+ input: tc.input || {},
135
+ });
136
+ }
137
+ return blocks;
138
+ }
139
+
140
+ getLocalTools() {
141
+ return [
142
+ {
143
+ name: "send_imessage",
144
+ description:
145
+ "Send an iMessage to a specified recipient. Use only when explicitly asked.",
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ recipient: {
150
+ type: "string",
151
+ description: "Email or phone number of the recipient",
152
+ },
153
+ text: { type: "string", description: "Message text to send" },
154
+ },
155
+ required: ["recipient", "text"],
156
+ },
157
+ local: true,
158
+ },
159
+ {
160
+ name: "send_long_reply",
161
+ description:
162
+ "Send a long reply that exceeds a single iMessage by splitting it into multiple messages. Call this instead of producing text when the response is long.",
163
+ inputSchema: {
164
+ type: "object",
165
+ properties: {
166
+ parts: {
167
+ type: "array",
168
+ items: { type: "string" },
169
+ description: "Array of message parts, each ≤ 1000 characters",
170
+ },
171
+ },
172
+ required: ["parts"],
173
+ },
174
+ local: true,
175
+ },
176
+ ];
177
+ }
178
+
179
+ async executeTool(toolCall, replyFn) {
180
+ LOG.info(`Tool call`, { name: toolCall.name, input: toolCall.input });
181
+
182
+ if (!isToolAllowed(toolCall.name, this.config.safety)) {
183
+ return `工具 ${toolCall.name} 被安全策略禁止`;
184
+ }
185
+
186
+ const command = toolCall.input?.command || toolCall.input?.cmd || toolCall.input?.shell;
187
+ if (typeof command === "string" && isCommandBlocked(command, this.config.safety.blockedCommands)) {
188
+ return `命令被安全策略禁止: ${command}`;
189
+ }
190
+
191
+ try {
192
+ switch (toolCall.name) {
193
+ case "send_imessage": {
194
+ const { recipient, text } = toolCall.input || {};
195
+ if (!recipient || !text) return "缺少 recipient 或 text";
196
+ sendMessage(recipient, text);
197
+ return `已发送消息给 ${recipient}`;
198
+ }
199
+
200
+ case "send_long_reply": {
201
+ const parts = toolCall.input?.parts || [];
202
+ for (const part of parts) {
203
+ await replyFn(part);
204
+ if (parts.length > 1) await sleep(1000);
205
+ }
206
+ return `已发送 ${parts.length} 条消息`;
207
+ }
208
+
209
+ default:
210
+ return await this.mcpManager.callTool(toolCall.name, toolCall.input);
211
+ }
212
+ } catch (err) {
213
+ LOG.error(`Tool execution failed`, {
214
+ name: toolCall.name,
215
+ error: err.message,
216
+ });
217
+ return `工具执行出错: ${err.message}`;
218
+ }
219
+ }
220
+
221
+ async sendReply(text, replyFn) {
222
+ if (text.length > 300) {
223
+ const parts = splitLongReply(text, 300);
224
+ for (const part of parts) {
225
+ await replyFn(part);
226
+ if (parts.length > 1) await sleep(500);
227
+ }
228
+ } else {
229
+ await replyFn(text);
230
+ }
231
+ }
232
+ }