apple-tools-mcp 1.1.3 → 1.2.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # apple-tools-mcp
2
2
 
3
- An MCP (Model Context Protocol) server that provides semantic search across Apple Mail, Messages, Calendar, and Contacts on macOS. Use natural language to search your emails, iMessages, calendar events, and contacts directly from Claude.
3
+ An MCP (Model Context Protocol) server that provides semantic search across Apple Mail, Messages, Calendar, and Contacts on macOS. Use natural language to search your emails, iMessages, calendar events, and contacts from any compatible MCP client over stdio.
4
4
 
5
5
  ## Features
6
6
 
@@ -14,7 +14,6 @@ An MCP (Model Context Protocol) server that provides semantic search across Appl
14
14
 
15
15
  - **macOS** (Ventura 13.0 or later recommended)
16
16
  - **Node.js** 18.0 or later
17
- - **Claude Desktop** app
18
17
  - **Full Disk Access** permission for the Node.js binary
19
18
 
20
19
  ## Installation
@@ -33,13 +32,15 @@ cd Apple-Tools-MCP
33
32
  npm install
34
33
  ```
35
34
 
36
- If you installed from source, point Claude Desktop at the local `index.js` instead of `npx` in step 3:
35
+ If you installed from source, point your MCP client at the local `index.js` instead of `npx` in step 3:
37
36
 
38
37
  ```json
39
38
  "command": "node",
40
39
  "args": ["/absolute/path/to/Apple-Tools-MCP/index.js"]
41
40
  ```
42
41
 
42
+ **Mac Mini** stays on a **global npm** install (`npm install -g apple-tools-mcp`) — no git clone on Mini. **MacBook / development** uses the clone above.
43
+
43
44
  ### 2. Grant Full Disk Access
44
45
 
45
46
  The MCP server needs access to read your Mail, Messages, and Calendar databases.
@@ -64,11 +65,18 @@ The MCP server needs access to read your Mail, Messages, and Calendar databases.
64
65
 
65
66
  7. Ensure the toggle for Node.js is enabled
66
67
 
67
- ### 3. Configure Claude Desktop
68
+ ### 3. Configure your MCP client
69
+
70
+ This server speaks MCP over **stdio**. Any compatible client can run it — Claude Desktop is one example, not the only one. Cursor, Grok Bot, and other stdio MCP clients work the same way: register the command below in that client's MCP settings.
71
+
72
+ **Command**
68
73
 
69
- Add to your Claude Desktop config file:
74
+ - `npx` with args `["-y", "apple-tools-mcp"]` (npm install)
75
+ - or `node` with args `["/absolute/path/to/Apple-Tools-MCP/index.js"]` (from source)
70
76
 
71
- **Location:** `~/Library/Application Support/Claude/claude_desktop_config.json`
77
+ **Example: Claude Desktop**
78
+
79
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
72
80
 
73
81
  ```json
74
82
  {
@@ -81,15 +89,24 @@ Add to your Claude Desktop config file:
81
89
  }
82
90
  ```
83
91
 
84
- ### 4. Restart Claude Desktop
92
+ Other clients use their own settings UI or config file. Use the same `command` and `args`; only the file path or UI differs.
93
+
94
+ MCP clients are **short-lived stdio** processes: they exit when the client closes stdin. Always-on indexing belongs on the **indexer daemon**, not a sleep-pipe wrapper around this binary.
95
+
96
+ ### 4. Restart your MCP client
85
97
 
86
- Quit and reopen Claude Desktop to load the MCP server.
98
+ Quit and reopen the client so it loads the server. For Claude Desktop, fully quit (Cmd+Q) and reopen.
87
99
 
88
100
  ## Building the Index
89
101
 
90
- On first use, the server will automatically build a vector index of your emails, messages, and calendar events. Email history is unlimited by default. This may take a while depending on the volume of data.
102
+ On first use, a vector index of your emails, messages, and calendar events is built automatically. Email history is unlimited by default. This may take a while depending on the volume of data.
91
103
 
92
- You can manually rebuild the index:
104
+ **Who indexes**
105
+
106
+ - **Indexer daemon running** (recommended on Mac Mini): the daemon owns `~/.apple-tools-mcp/indexer.lock` and refreshes `~/.apple-tools-mcp/vector-index/`. MCP stdio clients only search; they do not start background refresh.
107
+ - **No daemon** (default MacBook / Claude Desktop / Cursor): the MCP stdio process indexes **locally on startup**, same as previous versions, then exits when the client disconnects.
108
+
109
+ You can manually rebuild the index (stop the indexer daemon first if it is running, so it is not writing at the same time):
93
110
 
94
111
  ```bash
95
112
  # Index all email history (default)
@@ -101,9 +118,99 @@ APPLE_TOOLS_INDEX_DAYS_BACK=30 npm run build-index
101
118
 
102
119
  The index is stored in `~/.apple-tools-mcp/vector-index/`.
103
120
 
121
+ ## Index refresh interval
122
+
123
+ Resolved **once at process start**. Precedence (highest wins):
124
+
125
+ 1. `INDEX_INTERVAL_MS` environment variable (milliseconds or human form: `30s`, `1m`, `5m`, `1h`)
126
+ 2. `~/.apple-tools-mcp/config.json` keys `indexInterval` or `indexIntervalMs`
127
+ 3. Product default: **5 minutes** (`300000` ms) — typical MacBook / MCP local-fallback
128
+
129
+ Values are **clamped** to **15 seconds** minimum and **6 hours** maximum. Invalid JSON, unknown keys, and unparseable intervals are logged and ignored (the process does not crash). The effective interval is logged at start, for example:
130
+
131
+ ```text
132
+ Effective index refresh interval: 1m (60000 ms) [source=config]
133
+ ```
134
+
135
+ A warn line is also logged when clamping occurs.
136
+
137
+ ### Example `~/.apple-tools-mcp/config.json` (Mac Mini)
138
+
139
+ Recommended Mini always-on interval is **1 minute**. 30 seconds is allowed (at or above the 15s floor).
140
+
141
+ ```json
142
+ {
143
+ "indexInterval": "1m"
144
+ }
145
+ ```
146
+
147
+ Equivalent: `"indexIntervalMs": 60000`, or `INDEX_INTERVAL_MS=60000` (env overrides the file).
148
+
149
+ Missing `config.json` is fine — env then the 5-minute default apply.
150
+
151
+ ## Always-on indexer (Mac Mini LaunchAgent)
152
+
153
+ On Mini, run the **indexer daemon**, not a sleep-pipe wrapper around `apple-tools-mcp`. Grok Bot, Claude Desktop, and other clients still attach via short-lived stdio MCP (`npx -y apple-tools-mcp` or the global `apple-tools-mcp` bin).
154
+
155
+ **Entrypoint:** `node index.js --mode=indexer`
156
+ **Convenience bin:** `apple-tools-indexer` (same file; npm global install provides it)
157
+ **npm script (clone only):** `npm run indexer`
158
+ **One-shot rebuild:** `npm run build-index` (stop the indexer daemon first)
159
+
160
+ LaunchAgent should invoke **node + `--mode=indexer`** on the **global** package (Mini has no git clone). LaunchAgent does not inherit your shell `PATH`, so use absolute paths from `which node` and `npm root -g`.
161
+
162
+ ```bash
163
+ which node
164
+ # Apple Silicon Homebrew example: /opt/homebrew/bin/node
165
+ # Intel Homebrew / usr/local example: /usr/local/bin/node
166
+
167
+ npm root -g
168
+ # Example: /opt/homebrew/lib/node_modules
169
+ ```
170
+
171
+ Example `~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist`:
172
+
173
+ ```xml
174
+ <?xml version="1.0" encoding="UTF-8"?>
175
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
176
+ <plist version="1.0">
177
+ <dict>
178
+ <key>Label</key>
179
+ <string>com.apple-tools-mcp.indexer</string>
180
+ <key>ProgramArguments</key>
181
+ <array>
182
+ <string>/opt/homebrew/bin/node</string>
183
+ <string>/opt/homebrew/lib/node_modules/apple-tools-mcp/index.js</string>
184
+ <string>--mode=indexer</string>
185
+ </array>
186
+ <key>RunAtLoad</key>
187
+ <true/>
188
+ <key>KeepAlive</key>
189
+ <true/>
190
+ <key>EnvironmentVariables</key>
191
+ <dict>
192
+ <key>PATH</key>
193
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
194
+ </dict>
195
+ <key>StandardOutPath</key>
196
+ <string>/tmp/apple-tools-indexer.out.log</string>
197
+ <key>StandardErrorPath</key>
198
+ <string>/tmp/apple-tools-indexer.err.log</string>
199
+ </dict>
200
+ </plist>
201
+ ```
202
+
203
+ Replace the node and `node_modules` paths with the values from `which node` and `npm root -g`. Load it with:
204
+
205
+ ```bash
206
+ launchctl load ~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist
207
+ ```
208
+
209
+ KeepAlive belongs on this indexer job only — not on the MCP stdio process.
210
+
104
211
  ## Available Tools
105
212
 
106
- Once configured, Claude can use these tools:
213
+ Once configured, your MCP client can use these tools:
107
214
 
108
215
  ### Universal Search
109
216
 
@@ -159,7 +266,7 @@ Once configured, Claude can use these tools:
159
266
 
160
267
  ## Example Queries
161
268
 
162
- Ask Claude things like:
269
+ Ask your MCP client things like:
163
270
 
164
271
  - "Find emails from John about the quarterly report"
165
272
  - "What messages did I get from Mom last week?"
@@ -187,28 +294,31 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
187
294
  1. Check that the index was built: `ls ~/.apple-tools-mcp/vector-index/`
188
295
  2. Rebuild the index if needed: `npm run build-index`
189
296
 
190
- ### Server not appearing in Claude
297
+ ### Server not appearing in the MCP client
191
298
 
192
- 1. Verify your config file syntax is valid JSON
193
- 2. Restart Claude Desktop completely (Cmd+Q, then reopen)
194
- 3. Check Claude's MCP logs for errors
299
+ 1. Verify your client config is valid (JSON files must be valid JSON)
300
+ 2. Restart the MCP client completely (for Claude Desktop: Cmd+Q, then reopen)
301
+ 3. Check the client's MCP logs for errors
195
302
 
196
303
  ### Force rebuild the index
197
304
 
198
305
  If the index becomes corrupted or out of sync:
199
306
 
200
307
  ```bash
308
+ # If the Mini indexer LaunchAgent is running, unload it first
309
+ # launchctl unload ~/Library/LaunchAgents/com.apple-tools-mcp.indexer.plist
310
+
201
311
  # Remove existing index files
202
312
  rm -rf ~/.apple-tools-mcp/vector-index
203
313
  rm -f ~/.apple-tools-mcp/index-meta.json
204
314
  rm -f ~/.apple-tools-mcp/indexer.lock
205
315
 
206
- # Restart Claude Desktop to trigger a fresh rebuild
316
+ # Restart the indexer daemon or your MCP client to trigger a fresh rebuild
207
317
  ```
208
318
 
209
319
  ### Monitor indexing progress
210
320
 
211
- Watch the MCP server logs in real-time:
321
+ Watch the MCP server logs in your client. Log locations vary by client; Claude Desktop example:
212
322
 
213
323
  ```bash
214
324
  tail -f ~/Library/Logs/Claude/mcp-server-apple-tools.log
@@ -242,12 +352,15 @@ npm install -D vitest @vitest/coverage-v8 fast-check
242
352
  # Run tests
243
353
  npm test
244
354
 
245
- # Run tests with verbose coverage report
246
- npx vitest run --coverage --reporter=verbose
355
+ # Run the indexer daemon (owns indexer.lock + vector-index refresh)
356
+ npm run indexer
247
357
 
248
- # Build index with debug output
358
+ # One-shot rebuild (stop the indexer daemon first)
249
359
  npm run build-index
250
360
 
361
+ # Run tests with verbose coverage report
362
+ npx vitest run --coverage --reporter=verbose
363
+
251
364
  # Run audit to check index health
252
365
  npm run audit
253
366
  ```
package/index.js CHANGED
@@ -10,142 +10,110 @@ import fs from "fs";
10
10
  import path from "path";
11
11
  import { validateEmailPath, stripHtmlTags, unfoldRfc822Headers, validateLimit, validateDaysBack, validateWeekOffset, toUnixMillis } from "./lib/validators.js";
12
12
  import { isSearchBlockedByIndexing, cycleEndFlags, indexUnavailableMessage } from "./lib/indexGate.js";
13
+ import { isIndexerMode } from "./lib/processMode.js";
14
+ import { loadResolvedIndexInterval, logResolvedInterval } from "./lib/config.js";
15
+ import { createIndexerLock, DEFAULT_LOCK_HEARTBEAT_MS } from "./lib/indexerLock.js";
16
+ import {
17
+ shouldConnectMcpStdio,
18
+ bindStdinCloseExit,
19
+ beginIndexCycle,
20
+ applyIndexerCycleEnd,
21
+ mcpIndexingStartup,
22
+ waitForIndexerLock,
23
+ beginOwnedIndexing
24
+ } from "./lib/indexerRuntime.js";
13
25
 
14
26
  const PACKAGE_VERSION = JSON.parse(
15
27
  fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
16
28
  ).version;
17
29
 
30
+ // Canonical indexer entrypoint: `node index.js --mode=indexer` or `apple-tools-indexer`.
31
+ const INDEXER_MODE = isIndexerMode();
32
+ const resolvedIndexInterval = loadResolvedIndexInterval();
33
+ const INDEX_INTERVAL = resolvedIndexInterval.ms;
34
+ const LOCK_HEARTBEAT_MS = DEFAULT_LOCK_HEARTBEAT_MS;
35
+ const LOCK_RETRY_MS = 5 * 1000;
36
+
18
37
  // Lock file to prevent duplicate indexing processes
19
38
  const LOCK_FILE = path.join(process.env.HOME, ".apple-tools-mcp", "indexer.lock");
20
- const LOCK_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - if lock is older, assume hung process
39
+ const indexerLock = createIndexerLock({
40
+ lockFile: LOCK_FILE,
41
+ log: (msg) => console.error(msg)
42
+ });
21
43
  // True only while this process won the indexer lock. Distinct from
22
44
  // sessionIndexComplete: a secondary instance that lost the lock never
23
45
  // completes a local cycle and must not stay on "still indexing" forever.
24
46
  let ownsIndexLock = false;
25
47
 
26
48
  function acquireLock() {
27
- try {
28
- // Ensure directory exists first
29
- const lockDir = path.dirname(LOCK_FILE);
30
- if (!fs.existsSync(lockDir)) {
31
- fs.mkdirSync(lockDir, { recursive: true });
32
- }
49
+ const ok = indexerLock.acquire();
50
+ ownsIndexLock = indexerLock.ownsLock;
51
+ if (ok) {
52
+ startLockHeartbeat();
53
+ }
54
+ return ok;
55
+ }
33
56
 
34
- // Check for existing lock file
35
- if (fs.existsSync(LOCK_FILE)) {
36
- const lockData = fs.readFileSync(LOCK_FILE, "utf8");
37
- const [pidStr, timestampStr] = lockData.split(':');
38
- const pid = parseInt(pidStr);
39
- const timestamp = parseInt(timestampStr) || Date.now();
40
- const lockAge = Date.now() - timestamp;
41
-
42
- // If we already hold the lock, return true
43
- if (pid === process.pid) {
44
- ownsIndexLock = true;
45
- return true;
46
- }
57
+ function releaseLock() {
58
+ indexerLock.release();
59
+ ownsIndexLock = indexerLock.ownsLock;
60
+ if (!ownsIndexLock) {
61
+ stopLockHeartbeat();
62
+ }
63
+ }
47
64
 
48
- try {
49
- process.kill(pid, 0); // Check if process exists (signal 0 = no-op)
50
-
51
- // Process exists - check if lock is stale (hung process)
52
- if (lockAge > LOCK_TIMEOUT_MS) {
53
- console.error(`Lock file is ${Math.round(lockAge / 60000)} minutes old. Assuming hung process (PID ${pid}). Removing stale lock.`);
54
- fs.unlinkSync(LOCK_FILE);
55
- } else {
56
- console.error(`Another indexing instance running (PID ${pid}). Skipping indexing.`);
57
- ownsIndexLock = false;
58
- return false;
59
- }
60
- } catch {
61
- // Process doesn't exist, stale lock file - remove it
62
- console.error(`Removing stale lock file (PID ${pid} not running)`);
63
- fs.unlinkSync(LOCK_FILE);
64
- }
65
- }
65
+ function startLockHeartbeat() {
66
+ indexerLock.startHeartbeat(LOCK_HEARTBEAT_MS);
67
+ }
66
68
 
67
- // Use atomic 'wx' flag to create lock file exclusively
68
- // This prevents TOCTOU race condition - will throw EEXIST if file was created between check and write
69
- try {
70
- fs.writeFileSync(LOCK_FILE, `${process.pid}:${Date.now()}`, { flag: 'wx' });
71
- ownsIndexLock = true;
72
- return true;
73
- } catch (err) {
74
- if (err.code === 'EEXIST') {
75
- // Another process won the race
76
- console.error("Another process acquired lock during race. Skipping indexing.");
77
- ownsIndexLock = false;
78
- return false;
79
- }
80
- throw err; // Re-throw unexpected errors
81
- }
82
- } catch (e) {
83
- console.error("Lock file error:", e.message);
84
- ownsIndexLock = false;
85
- return false; // On error, fail safe - don't proceed
86
- }
69
+ function stopLockHeartbeat() {
70
+ indexerLock.stopHeartbeat();
87
71
  }
88
72
 
89
- function releaseLock() {
90
- try {
91
- if (fs.existsSync(LOCK_FILE)) {
92
- const lockData = fs.readFileSync(LOCK_FILE, "utf8");
93
- const [pidStr] = lockData.split(':');
94
- const pid = parseInt(pidStr);
95
- if (pid === process.pid) {
96
- fs.unlinkSync(LOCK_FILE);
97
- ownsIndexLock = false;
98
- console.error(`Released lock file (PID ${process.pid})`);
99
- }
100
- }
101
- } catch (err) {
102
- // Log error but don't throw - we're likely shutting down
103
- console.error(`Error releasing lock: ${err.message}`);
73
+ function shutdownIndexing(exitCode) {
74
+ stopBackgroundIndexing();
75
+ stopLockHeartbeat();
76
+ releaseLock();
77
+ if (exitCode !== undefined) {
78
+ process.exit(exitCode);
104
79
  }
105
80
  }
106
81
 
107
82
  // Clean up lock and timer on exit
108
83
  process.on("exit", () => {
109
84
  stopBackgroundIndexing();
85
+ stopLockHeartbeat();
110
86
  releaseLock();
111
87
  });
112
88
  process.on("SIGINT", () => {
113
- stopBackgroundIndexing();
114
- releaseLock();
89
+ shutdownIndexing();
115
90
  process.exit();
116
91
  });
117
92
  process.on("SIGTERM", () => {
118
- stopBackgroundIndexing();
119
- releaseLock();
93
+ shutdownIndexing();
120
94
  process.exit();
121
95
  });
122
96
  process.on("SIGHUP", () => {
123
- stopBackgroundIndexing();
124
- releaseLock();
97
+ shutdownIndexing();
125
98
  process.exit();
126
99
  });
127
100
 
128
101
  // Handle uncaught errors - cleanup before crashing
129
102
  process.on("uncaughtException", (err) => {
130
103
  console.error("Uncaught exception:", err);
131
- stopBackgroundIndexing();
132
- releaseLock();
133
- process.exit(1);
104
+ shutdownIndexing(1);
134
105
  });
135
106
 
136
107
  process.on("unhandledRejection", (reason, promise) => {
137
108
  console.error("Unhandled rejection at:", promise, "reason:", reason);
138
- stopBackgroundIndexing();
139
- releaseLock();
140
- process.exit(1);
109
+ shutdownIndexing(1);
141
110
  });
142
111
 
143
- // Exit when stdin closes (Claude client disconnected)
144
- process.stdin.on("close", () => {
112
+ // MCP stdio clients exit when the host closes stdin. The indexer daemon must
113
+ // not — LaunchAgent / KeepAlive often attaches stdin to /dev/null.
114
+ bindStdinCloseExit(process.stdin, INDEXER_MODE, () => {
145
115
  console.error("Client disconnected. Exiting.");
146
- stopBackgroundIndexing();
147
- releaseLock();
148
- process.exit(0);
116
+ shutdownIndexing(0);
149
117
  });
150
118
 
151
119
  // Vector search imports
@@ -199,10 +167,9 @@ let sessionIndexComplete = false; // Track if this session's indexing is done
199
167
  let isFirstEverRun = true; // True if no index exists yet
200
168
  let lastIndexTime = 0;
201
169
  let lastProgressTime = 0; // Track when we last made progress (for hung detection)
202
- // Allow environment variable to override default 5-minute interval
203
- const INDEX_INTERVAL = parseInt(process.env.INDEX_INTERVAL_MS || (5 * 60 * 1000));
204
170
  let indexTimer = null;
205
171
  let progressCheckTimer = null;
172
+ let loggedIndexInterval = false;
206
173
 
207
174
  // Check if this is the first ever run (no index exists)
208
175
  async function checkIfFirstRun() {
@@ -223,13 +190,15 @@ function getIndexingMessage() {
223
190
 
224
191
  // Run a single indexing cycle (called by background timer)
225
192
  function runIndexCycle() {
226
- if (indexingInProgress) {
227
- console.error("Indexing already in progress, skipping cycle");
193
+ const cycle = beginIndexCycle(indexingInProgress);
194
+ if (!cycle.started) {
228
195
  return;
229
196
  }
197
+ indexingInProgress = cycle.indexingInProgress;
230
198
 
231
199
  // Safety net: check lock before indexing
232
200
  if (!acquireLock()) {
201
+ indexingInProgress = false;
233
202
  console.error("Another instance is indexing. Skipping.");
234
203
  return;
235
204
  }
@@ -299,6 +268,11 @@ function triggerIndexIfNeeded() {
299
268
 
300
269
  // Start continuous background indexing
301
270
  function startBackgroundIndexing() {
271
+ if (!loggedIndexInterval) {
272
+ logResolvedInterval(resolvedIndexInterval);
273
+ loggedIndexInterval = true;
274
+ }
275
+
302
276
  // Run indexing immediately on startup
303
277
  runIndexCycle();
304
278
 
@@ -307,7 +281,7 @@ function startBackgroundIndexing() {
307
281
  runIndexCycle();
308
282
  }, INDEX_INTERVAL);
309
283
 
310
- console.error(`Background indexing started (interval: ${INDEX_INTERVAL / 1000}s)`);
284
+ console.error(`Background indexing started (interval: ${resolvedIndexInterval.human} / ${INDEX_INTERVAL} ms)`);
311
285
  }
312
286
 
313
287
  // Stop background indexing and clean up timers
@@ -323,17 +297,22 @@ function stopBackgroundIndexing() {
323
297
  console.error("Background indexing stopped");
324
298
  }
325
299
 
326
- // Unblock searches and drop the indexer lock after a cycle ends.
327
- // Must run on failure as well as success so tools are not stuck forever.
300
+ // Unblock searches after a cycle ends. Must run on failure as well as success
301
+ // so tools are not stuck forever. The indexer daemon keeps indexer.lock for
302
+ // the process lifetime; MCP local-fallback still releases between cycles.
328
303
  function applyCycleEnd(success) {
329
- const flags = cycleEndFlags(success);
330
- indexingInProgress = flags.indexingInProgress;
331
- sessionIndexComplete = flags.sessionIndexComplete;
332
- ownsIndexLock = flags.ownsIndexLock;
333
- if (flags.isFirstEverRun === false) {
304
+ const result = applyIndexerCycleEnd({
305
+ success,
306
+ indexerMode: INDEXER_MODE,
307
+ cycleEndFlags,
308
+ releaseLock
309
+ });
310
+ indexingInProgress = result.indexingInProgress;
311
+ sessionIndexComplete = result.sessionIndexComplete;
312
+ ownsIndexLock = result.ownsIndexLock;
313
+ if (result.isFirstEverRun === false) {
334
314
  isFirstEverRun = false;
335
315
  }
336
- releaseLock();
337
316
  }
338
317
 
339
318
  // Index-backed tools wait only while THIS process owns the lock and has not
@@ -345,13 +324,35 @@ function stillIndexingMessage() {
345
324
  return null;
346
325
  }
347
326
 
327
+ function waitForLockAndStartDaemon() {
328
+ waitForIndexerLock(acquireLock, {
329
+ retryMs: LOCK_RETRY_MS,
330
+ onAcquired: () => {
331
+ beginOwnedIndexing({
332
+ startHeartbeat: startLockHeartbeat,
333
+ startBackground: startBackgroundIndexing
334
+ });
335
+ }
336
+ });
337
+ }
338
+
348
339
  // Initialize and start indexing
349
340
  async function initializeIndexing() {
350
341
  isFirstEverRun = await checkIfFirstRun();
351
342
 
352
- // Try to acquire lock - if another instance is indexing, skip background
353
- // indexing but keep the MCP server running so search still works.
354
- if (!acquireLock()) {
343
+ if (INDEXER_MODE) {
344
+ console.error(`Apple Tools MCP indexer running (v${PACKAGE_VERSION})`);
345
+ logResolvedInterval(resolvedIndexInterval);
346
+ loggedIndexInterval = true;
347
+ waitForLockAndStartDaemon();
348
+ return;
349
+ }
350
+
351
+ // MCP stdio: if the indexer daemon (or another instance) holds the lock,
352
+ // skip background refresh and use the shared index. If nothing holds the
353
+ // lock, index locally as before so the stdio happy path still works.
354
+ const startup = mcpIndexingStartup(() => acquireLock());
355
+ if (!startup.startBackground) {
355
356
  console.error("Another apple-tools-mcp instance is indexing. Server will run without background indexing.");
356
357
  // Lost lock is not "still indexing": this process will never complete a
357
358
  // local cycle. Searches proceed whenever isIndexReady() is true.
@@ -359,8 +360,13 @@ async function initializeIndexing() {
359
360
  return;
360
361
  }
361
362
 
362
- // Start background indexing
363
- startBackgroundIndexing();
363
+ // Start background indexing (local fallback when no daemon is running)
364
+ // using the same heartbeat path as the daemon so a long first-index cycle
365
+ // cannot look like a stale lock to another waiter.
366
+ beginOwnedIndexing({
367
+ startHeartbeat: startLockHeartbeat,
368
+ startBackground: startBackgroundIndexing
369
+ });
364
370
  }
365
371
 
366
372
  // Start indexing immediately on server startup
@@ -1524,7 +1530,10 @@ async function main() {
1524
1530
  const transport = new StdioServerTransport();
1525
1531
  await server.connect(transport);
1526
1532
  console.error(`Apple Tools MCP server running (v${PACKAGE_VERSION})`);
1527
- // Background indexing runs automatically on startup and every INDEX_INTERVAL
1533
+ // Background indexing: indexer daemon when --mode=indexer; otherwise local
1534
+ // fallback on this stdio process only if indexer.lock is free.
1528
1535
  }
1529
1536
 
1530
- main().catch(console.error);
1537
+ if (shouldConnectMcpStdio(INDEXER_MODE)) {
1538
+ main().catch(console.error);
1539
+ }