apple-tools-mcp 1.1.4 → 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 +111 -6
- package/index.js +119 -110
- package/lib/config.js +312 -0
- package/lib/indexerLock.js +376 -0
- package/lib/indexerRuntime.js +142 -0
- package/lib/processMode.js +28 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ If you installed from source, point your MCP client at the local `index.js` inst
|
|
|
39
39
|
"args": ["/absolute/path/to/Apple-Tools-MCP/index.js"]
|
|
40
40
|
```
|
|
41
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
|
+
|
|
42
44
|
### 2. Grant Full Disk Access
|
|
43
45
|
|
|
44
46
|
The MCP server needs access to read your Mail, Messages, and Calendar databases.
|
|
@@ -89,15 +91,22 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
89
91
|
|
|
90
92
|
Other clients use their own settings UI or config file. Use the same `command` and `args`; only the file path or UI differs.
|
|
91
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
|
+
|
|
92
96
|
### 4. Restart your MCP client
|
|
93
97
|
|
|
94
98
|
Quit and reopen the client so it loads the server. For Claude Desktop, fully quit (Cmd+Q) and reopen.
|
|
95
99
|
|
|
96
100
|
## Building the Index
|
|
97
101
|
|
|
98
|
-
On first use,
|
|
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.
|
|
103
|
+
|
|
104
|
+
**Who indexes**
|
|
99
105
|
|
|
100
|
-
|
|
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):
|
|
101
110
|
|
|
102
111
|
```bash
|
|
103
112
|
# Index all email history (default)
|
|
@@ -109,6 +118,96 @@ APPLE_TOOLS_INDEX_DAYS_BACK=30 npm run build-index
|
|
|
109
118
|
|
|
110
119
|
The index is stored in `~/.apple-tools-mcp/vector-index/`.
|
|
111
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
|
+
|
|
112
211
|
## Available Tools
|
|
113
212
|
|
|
114
213
|
Once configured, your MCP client can use these tools:
|
|
@@ -206,12 +305,15 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
|
|
|
206
305
|
If the index becomes corrupted or out of sync:
|
|
207
306
|
|
|
208
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
|
+
|
|
209
311
|
# Remove existing index files
|
|
210
312
|
rm -rf ~/.apple-tools-mcp/vector-index
|
|
211
313
|
rm -f ~/.apple-tools-mcp/index-meta.json
|
|
212
314
|
rm -f ~/.apple-tools-mcp/indexer.lock
|
|
213
315
|
|
|
214
|
-
# Restart your MCP client to trigger a fresh rebuild
|
|
316
|
+
# Restart the indexer daemon or your MCP client to trigger a fresh rebuild
|
|
215
317
|
```
|
|
216
318
|
|
|
217
319
|
### Monitor indexing progress
|
|
@@ -250,12 +352,15 @@ npm install -D vitest @vitest/coverage-v8 fast-check
|
|
|
250
352
|
# Run tests
|
|
251
353
|
npm test
|
|
252
354
|
|
|
253
|
-
# Run
|
|
254
|
-
|
|
355
|
+
# Run the indexer daemon (owns indexer.lock + vector-index refresh)
|
|
356
|
+
npm run indexer
|
|
255
357
|
|
|
256
|
-
#
|
|
358
|
+
# One-shot rebuild (stop the indexer daemon first)
|
|
257
359
|
npm run build-index
|
|
258
360
|
|
|
361
|
+
# Run tests with verbose coverage report
|
|
362
|
+
npx vitest run --coverage --reporter=verbose
|
|
363
|
+
|
|
259
364
|
# Run audit to check index health
|
|
260
365
|
npm run audit
|
|
261
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
const ok = indexerLock.acquire();
|
|
50
|
+
ownsIndexLock = indexerLock.ownsLock;
|
|
51
|
+
if (ok) {
|
|
52
|
+
startLockHeartbeat();
|
|
53
|
+
}
|
|
54
|
+
return ok;
|
|
55
|
+
}
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
114
|
-
releaseLock();
|
|
89
|
+
shutdownIndexing();
|
|
115
90
|
process.exit();
|
|
116
91
|
});
|
|
117
92
|
process.on("SIGTERM", () => {
|
|
118
|
-
|
|
119
|
-
releaseLock();
|
|
93
|
+
shutdownIndexing();
|
|
120
94
|
process.exit();
|
|
121
95
|
});
|
|
122
96
|
process.on("SIGHUP", () => {
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
releaseLock();
|
|
140
|
-
process.exit(1);
|
|
109
|
+
shutdownIndexing(1);
|
|
141
110
|
});
|
|
142
111
|
|
|
143
|
-
//
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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: ${
|
|
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
|
|
327
|
-
//
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1537
|
+
if (shouldConnectMcpStdio(INDEXER_MODE)) {
|
|
1538
|
+
main().catch(console.error);
|
|
1539
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User config for apple-tools-mcp.
|
|
3
|
+
*
|
|
4
|
+
* Path is fixed at ~/.apple-tools-mcp/config.json (or $HOME). Arbitrary paths
|
|
5
|
+
* from config contents are ignored — config is treated as data, not as
|
|
6
|
+
* instructions or path overrides.
|
|
7
|
+
*
|
|
8
|
+
* Interval precedence (highest wins):
|
|
9
|
+
* 1. INDEX_INTERVAL_MS environment variable
|
|
10
|
+
* 2. config.json `indexInterval` (or `indexIntervalMs`)
|
|
11
|
+
* 3. Product default (5 minutes)
|
|
12
|
+
*
|
|
13
|
+
* Values are clamped to [15s, 6h]. Human forms like `30s`, `1m`, `1h` are accepted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "fs";
|
|
17
|
+
import os from "os";
|
|
18
|
+
import path from "path";
|
|
19
|
+
|
|
20
|
+
export const APPLE_TOOLS_DIR_NAME = ".apple-tools-mcp";
|
|
21
|
+
export const CONFIG_FILE_NAME = "config.json";
|
|
22
|
+
|
|
23
|
+
/** MacBook / MCP local-fallback default. */
|
|
24
|
+
export const DEFAULT_INDEX_INTERVAL_MS = 5 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/** Documented floor: 15 seconds (30s remains allowed). */
|
|
27
|
+
export const MIN_INDEX_INTERVAL_MS = 15 * 1000;
|
|
28
|
+
|
|
29
|
+
/** Documented ceiling: 6 hours. */
|
|
30
|
+
export const MAX_INDEX_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** Recommended Mini always-on value (set in config.json, not the product default). */
|
|
33
|
+
export const MINI_RECOMMENDED_INDEX_INTERVAL_MS = 60 * 1000;
|
|
34
|
+
|
|
35
|
+
const KNOWN_CONFIG_KEYS = new Set(["indexInterval", "indexIntervalMs"]);
|
|
36
|
+
|
|
37
|
+
const MAX_DURATION_STRING_LENGTH = 32;
|
|
38
|
+
|
|
39
|
+
function defaultWarn(message) {
|
|
40
|
+
console.error(message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Directory that holds config.json, indexer.lock, and vector-index.
|
|
45
|
+
* Always under the user home directory — never a path from config contents.
|
|
46
|
+
*
|
|
47
|
+
* @param {{ env?: NodeJS.ProcessEnv, homedir?: () => string }} [options]
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function getAppleToolsDir(options = {}) {
|
|
51
|
+
const env = options.env || process.env;
|
|
52
|
+
const homedir = options.homedir || (() => os.homedir());
|
|
53
|
+
const home = env.HOME || homedir();
|
|
54
|
+
return path.join(home, APPLE_TOOLS_DIR_NAME);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {{ env?: NodeJS.ProcessEnv, homedir?: () => string }} [options]
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function getConfigPath(options = {}) {
|
|
62
|
+
return path.join(getAppleToolsDir(options), CONFIG_FILE_NAME);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse a millisecond count or human duration (`500ms`, `30s`, `1m`, `1h`).
|
|
67
|
+
* @param {unknown} value
|
|
68
|
+
* @returns {number|null} milliseconds, or null if unparseable
|
|
69
|
+
*/
|
|
70
|
+
export function parseDuration(value) {
|
|
71
|
+
if (typeof value === "number") {
|
|
72
|
+
if (!Number.isFinite(value)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
if (typeof value !== "string") {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const trimmed = value.trim();
|
|
81
|
+
if (trimmed.length === 0 || trimmed.length > MAX_DURATION_STRING_LENGTH) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (/^\d+$/.test(trimmed)) {
|
|
85
|
+
const n = Number(trimmed);
|
|
86
|
+
if (!Number.isSafeInteger(n)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return n;
|
|
90
|
+
}
|
|
91
|
+
const match = /^(\d+)(ms|s|m|h)$/i.exec(trimmed);
|
|
92
|
+
if (!match) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const n = Number(match[1]);
|
|
96
|
+
if (!Number.isSafeInteger(n)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const unit = match[2].toLowerCase();
|
|
100
|
+
const multiplier = unit === "ms" ? 1
|
|
101
|
+
: unit === "s" ? 1000
|
|
102
|
+
: unit === "m" ? 60 * 1000
|
|
103
|
+
: 60 * 60 * 1000;
|
|
104
|
+
const result = n * multiplier;
|
|
105
|
+
if (!Number.isSafeInteger(result)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Compact human form for logs (`1m`, `30s`, `6h`, or `1500ms`).
|
|
113
|
+
* @param {number} ms
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
export function formatIntervalMs(ms) {
|
|
117
|
+
if (!Number.isFinite(ms)) {
|
|
118
|
+
return String(ms);
|
|
119
|
+
}
|
|
120
|
+
const rounded = Math.round(ms);
|
|
121
|
+
if (rounded % (60 * 60 * 1000) === 0) {
|
|
122
|
+
return `${rounded / (60 * 60 * 1000)}h`;
|
|
123
|
+
}
|
|
124
|
+
if (rounded % (60 * 1000) === 0) {
|
|
125
|
+
return `${rounded / (60 * 1000)}m`;
|
|
126
|
+
}
|
|
127
|
+
if (rounded % 1000 === 0) {
|
|
128
|
+
return `${rounded / 1000}s`;
|
|
129
|
+
}
|
|
130
|
+
return `${rounded}ms`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Clamp a parsed millisecond value. Invalid input yields the default (not the min).
|
|
135
|
+
*
|
|
136
|
+
* @param {unknown} raw
|
|
137
|
+
* @param {{ defaultMs?: number, minMs?: number, maxMs?: number }} [bounds]
|
|
138
|
+
* @returns {{ ms: number, human: string, clamped: boolean, invalid: boolean, requestedMs: number|null }}
|
|
139
|
+
*/
|
|
140
|
+
export function clampIndexInterval(raw, bounds = {}) {
|
|
141
|
+
const defaultMs = bounds.defaultMs ?? DEFAULT_INDEX_INTERVAL_MS;
|
|
142
|
+
const minMs = bounds.minMs ?? MIN_INDEX_INTERVAL_MS;
|
|
143
|
+
const maxMs = bounds.maxMs ?? MAX_INDEX_INTERVAL_MS;
|
|
144
|
+
const requestedMs = parseDuration(raw);
|
|
145
|
+
|
|
146
|
+
if (requestedMs === null) {
|
|
147
|
+
return {
|
|
148
|
+
ms: defaultMs,
|
|
149
|
+
human: formatIntervalMs(defaultMs),
|
|
150
|
+
clamped: false,
|
|
151
|
+
invalid: true,
|
|
152
|
+
requestedMs: null
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const clampedMs = Math.min(maxMs, Math.max(minMs, requestedMs));
|
|
157
|
+
return {
|
|
158
|
+
ms: clampedMs,
|
|
159
|
+
human: formatIntervalMs(clampedMs),
|
|
160
|
+
clamped: clampedMs !== requestedMs,
|
|
161
|
+
invalid: false,
|
|
162
|
+
requestedMs
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Load ~/.apple-tools-mcp/config.json. Missing file is fine. Invalid JSON
|
|
168
|
+
* does not throw — callers get empty data plus a warn log.
|
|
169
|
+
*
|
|
170
|
+
* @param {{
|
|
171
|
+
* configPath?: string,
|
|
172
|
+
* env?: NodeJS.ProcessEnv,
|
|
173
|
+
* readFile?: (p: string) => string,
|
|
174
|
+
* exists?: (p: string) => boolean,
|
|
175
|
+
* warn?: (msg: string) => void
|
|
176
|
+
* }} [options]
|
|
177
|
+
* @returns {{ data: Record<string, unknown>, missing: boolean, invalid: boolean, path: string }}
|
|
178
|
+
*/
|
|
179
|
+
export function loadConfigFile(options = {}) {
|
|
180
|
+
const warn = options.warn || defaultWarn;
|
|
181
|
+
const configPath = options.configPath || getConfigPath({ env: options.env });
|
|
182
|
+
const exists = options.exists || ((p) => fs.existsSync(p));
|
|
183
|
+
const readFile = options.readFile || ((p) => fs.readFileSync(p, "utf8"));
|
|
184
|
+
|
|
185
|
+
if (!exists(configPath)) {
|
|
186
|
+
return { data: {}, missing: true, invalid: false, path: configPath };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const raw = readFile(configPath);
|
|
191
|
+
const parsed = JSON.parse(raw);
|
|
192
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
193
|
+
warn("Invalid config.json: expected a JSON object. Using defaults.");
|
|
194
|
+
return { data: {}, missing: false, invalid: true, path: configPath };
|
|
195
|
+
}
|
|
196
|
+
for (const key of Object.keys(parsed)) {
|
|
197
|
+
if (!KNOWN_CONFIG_KEYS.has(key)) {
|
|
198
|
+
warn(`Ignoring unknown config key: ${key}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { data: parsed, missing: false, invalid: false, path: configPath };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const message = err && err.message ? err.message : "parse error";
|
|
204
|
+
warn(`Invalid config.json (${message}). Using defaults.`);
|
|
205
|
+
return { data: {}, missing: false, invalid: true, path: configPath };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function fileIntervalRaw(data) {
|
|
210
|
+
if (!data || typeof data !== "object") {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
if (Object.prototype.hasOwnProperty.call(data, "indexInterval")) {
|
|
214
|
+
return data.indexInterval;
|
|
215
|
+
}
|
|
216
|
+
if (Object.prototype.hasOwnProperty.call(data, "indexIntervalMs")) {
|
|
217
|
+
return data.indexIntervalMs;
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolve the index refresh interval once at process start.
|
|
224
|
+
*
|
|
225
|
+
* @param {{
|
|
226
|
+
* env?: NodeJS.ProcessEnv,
|
|
227
|
+
* configPath?: string,
|
|
228
|
+
* fileData?: Record<string, unknown>,
|
|
229
|
+
* warn?: (msg: string) => void
|
|
230
|
+
* }} [options]
|
|
231
|
+
* @returns {{
|
|
232
|
+
* ms: number,
|
|
233
|
+
* human: string,
|
|
234
|
+
* source: "env" | "config" | "default",
|
|
235
|
+
* clamped: boolean,
|
|
236
|
+
* invalid: boolean,
|
|
237
|
+
* requestedMs: number|null,
|
|
238
|
+
* raw: unknown
|
|
239
|
+
* }}
|
|
240
|
+
*/
|
|
241
|
+
export function resolveIndexInterval(options = {}) {
|
|
242
|
+
const env = options.env || process.env;
|
|
243
|
+
const warn = options.warn || defaultWarn;
|
|
244
|
+
const envRaw = env.INDEX_INTERVAL_MS;
|
|
245
|
+
const envSet = envRaw !== undefined && envRaw !== "";
|
|
246
|
+
|
|
247
|
+
let source = "default";
|
|
248
|
+
let raw = DEFAULT_INDEX_INTERVAL_MS;
|
|
249
|
+
|
|
250
|
+
if (envSet) {
|
|
251
|
+
source = "env";
|
|
252
|
+
raw = envRaw;
|
|
253
|
+
} else {
|
|
254
|
+
const data = options.fileData !== undefined
|
|
255
|
+
? options.fileData
|
|
256
|
+
: loadConfigFile({ configPath: options.configPath, env, warn }).data;
|
|
257
|
+
const fromFile = fileIntervalRaw(data);
|
|
258
|
+
if (fromFile !== undefined) {
|
|
259
|
+
source = "config";
|
|
260
|
+
raw = fromFile;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const clamped = clampIndexInterval(raw);
|
|
265
|
+
if (clamped.invalid && source !== "default") {
|
|
266
|
+
warn(`Invalid index interval ${JSON.stringify(raw)} from ${source}; using default ${clamped.human} (${clamped.ms} ms)`);
|
|
267
|
+
} else if (clamped.clamped) {
|
|
268
|
+
const fromHuman = formatIntervalMs(clamped.requestedMs);
|
|
269
|
+
warn(
|
|
270
|
+
`Index interval ${fromHuman} (${clamped.requestedMs} ms) from ${source} is outside ${formatIntervalMs(MIN_INDEX_INTERVAL_MS)}–${formatIntervalMs(MAX_INDEX_INTERVAL_MS)}; clamped to ${clamped.human} (${clamped.ms} ms)`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
ms: clamped.ms,
|
|
276
|
+
human: clamped.human,
|
|
277
|
+
source: clamped.invalid && source !== "default" ? "default" : source,
|
|
278
|
+
clamped: clamped.clamped,
|
|
279
|
+
invalid: clamped.invalid,
|
|
280
|
+
requestedMs: clamped.requestedMs,
|
|
281
|
+
raw
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Load config from disk (if present) and resolve the interval.
|
|
287
|
+
* @param {{ env?: NodeJS.ProcessEnv, configPath?: string, warn?: (msg: string) => void }} [options]
|
|
288
|
+
*/
|
|
289
|
+
export function loadResolvedIndexInterval(options = {}) {
|
|
290
|
+
const warn = options.warn || defaultWarn;
|
|
291
|
+
const loaded = loadConfigFile({
|
|
292
|
+
configPath: options.configPath,
|
|
293
|
+
env: options.env,
|
|
294
|
+
warn
|
|
295
|
+
});
|
|
296
|
+
return resolveIndexInterval({
|
|
297
|
+
env: options.env,
|
|
298
|
+
fileData: loaded.data,
|
|
299
|
+
warn
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Log the effective interval once. Human form and milliseconds.
|
|
305
|
+
* @param {{ ms: number, human: string, source: string, clamped: boolean }} resolved
|
|
306
|
+
* @param {{ log?: (msg: string) => void }} [options]
|
|
307
|
+
*/
|
|
308
|
+
export function logResolvedInterval(resolved, options = {}) {
|
|
309
|
+
const log = options.log || defaultWarn;
|
|
310
|
+
const clampedNote = resolved.clamped ? ", clamped" : "";
|
|
311
|
+
log(`Effective index refresh interval: ${resolved.human} (${resolved.ms} ms) [source=${resolved.source}${clampedNote}]`);
|
|
312
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* indexer.lock acquire / release / heartbeat.
|
|
3
|
+
*
|
|
4
|
+
* A live holder is never displaced, even if the lock timestamp is old
|
|
5
|
+
* (blocked event loop, long index cycle). Dead-PID takeover never renames
|
|
6
|
+
* the live lock path. Waiters take a wx mutex, re-read, unlink only if the
|
|
7
|
+
* contents are still the expected dead lock, wx-create the new lock, then
|
|
8
|
+
* drop the mutex — so a peer's freshly created lock is never deleted.
|
|
9
|
+
* An orphaned takeover mutex is never stolen with compare-then-unlink of
|
|
10
|
+
* the live `.takeover` path: waiters wx a fence named by the dead PID.
|
|
11
|
+
* Empty or unparsable mutex/lock contents are unknown — do not fence or steal
|
|
12
|
+
* (covers wx open-then-write and heartbeat truncate-then-write windows).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_LOCK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
19
|
+
export const DEFAULT_LOCK_HEARTBEAT_MS = 60 * 1000;
|
|
20
|
+
export const TAKEOVER_MUTEX_SUFFIX = ".takeover";
|
|
21
|
+
export const TAKEOVER_FENCE_DEPTH = 8;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} lockFile
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function getTakeoverMutexPath(lockFile) {
|
|
28
|
+
return `${lockFile}${TAKEOVER_MUTEX_SUFFIX}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* wx fence used when indexer.lock.takeover is orphaned (dead PID).
|
|
33
|
+
* @param {string} lockFile
|
|
34
|
+
* @param {...number|string} deadIds
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function getTakeoverFencePath(lockFile, ...deadIds) {
|
|
38
|
+
let p = getTakeoverMutexPath(lockFile);
|
|
39
|
+
for (const id of deadIds) {
|
|
40
|
+
p = `${p}.${id}`;
|
|
41
|
+
}
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {unknown} pid
|
|
47
|
+
* @param {(pid: number, signal: number) => void} [killFn]
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function isProcessAlive(pid, killFn = (p, signal) => process.kill(p, signal)) {
|
|
51
|
+
try {
|
|
52
|
+
killFn(pid, 0);
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} text
|
|
61
|
+
* @returns {{ pid: number, timestamp: number, raw: string } | null}
|
|
62
|
+
*/
|
|
63
|
+
export function parseLockData(text) {
|
|
64
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const [pidStr, timestampStr] = text.split(":");
|
|
68
|
+
const pid = parseInt(pidStr, 10);
|
|
69
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
70
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
pid,
|
|
75
|
+
timestamp: Number.isInteger(timestamp) ? timestamp : 0,
|
|
76
|
+
raw: text
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {number} pid
|
|
82
|
+
* @param {number} timestamp
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function formatLockData(pid, timestamp) {
|
|
86
|
+
return `${pid}:${timestamp}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {{
|
|
91
|
+
* lockFile: string,
|
|
92
|
+
* pid?: number,
|
|
93
|
+
* now?: () => number,
|
|
94
|
+
* timeoutMs?: number,
|
|
95
|
+
* isAlive?: (pid: number) => boolean,
|
|
96
|
+
* fsApi?: Pick<typeof fs, "existsSync" | "readFileSync" | "writeFileSync" | "unlinkSync" | "mkdirSync">,
|
|
97
|
+
* log?: (msg: string) => void,
|
|
98
|
+
* setIntervalFn?: typeof setInterval,
|
|
99
|
+
* clearIntervalFn?: typeof clearInterval
|
|
100
|
+
* }} options
|
|
101
|
+
*/
|
|
102
|
+
export function createIndexerLock(options) {
|
|
103
|
+
const lockFile = options.lockFile;
|
|
104
|
+
const mutexPath = getTakeoverMutexPath(lockFile);
|
|
105
|
+
const pid = options.pid ?? process.pid;
|
|
106
|
+
const now = options.now || (() => Date.now());
|
|
107
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
108
|
+
const isAlive = options.isAlive || ((holderPid) => isProcessAlive(holderPid));
|
|
109
|
+
const fsApi = options.fsApi || fs;
|
|
110
|
+
const log = options.log || ((msg) => console.error(msg));
|
|
111
|
+
const setIntervalFn = options.setIntervalFn || setInterval;
|
|
112
|
+
const clearIntervalFn = options.clearIntervalFn || clearInterval;
|
|
113
|
+
|
|
114
|
+
let ownsLock = false;
|
|
115
|
+
let heartbeatTimer = null;
|
|
116
|
+
let heldMutexPath = null;
|
|
117
|
+
|
|
118
|
+
function readLockFile() {
|
|
119
|
+
if (!fsApi.existsSync(lockFile)) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return fsApi.readFileSync(lockFile, "utf8");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function skipLiveHolder(parsed) {
|
|
126
|
+
const lockAge = now() - parsed.timestamp;
|
|
127
|
+
if (lockAge > timeoutMs) {
|
|
128
|
+
log(
|
|
129
|
+
`Lock file is ${Math.round(lockAge / 60000)} minutes old, but PID ${parsed.pid} is still running. Not taking over.`
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
log(`Another indexing instance running (PID ${parsed.pid}). Skipping indexing.`);
|
|
133
|
+
}
|
|
134
|
+
ownsLock = false;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function refreshOwned() {
|
|
139
|
+
ownsLock = true;
|
|
140
|
+
try {
|
|
141
|
+
fsApi.writeFileSync(lockFile, formatLockData(pid, now()));
|
|
142
|
+
} catch {
|
|
143
|
+
// Heartbeat or the next cycle can retry the write.
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function tryAcquireMutex() {
|
|
149
|
+
const token = formatLockData(pid, now());
|
|
150
|
+
let candidate = mutexPath;
|
|
151
|
+
try {
|
|
152
|
+
for (let depth = 0; depth < TAKEOVER_FENCE_DEPTH; depth++) {
|
|
153
|
+
let exists = false;
|
|
154
|
+
for (let spin = 0; spin < 4; spin++) {
|
|
155
|
+
try {
|
|
156
|
+
fsApi.writeFileSync(candidate, token, { flag: "wx" });
|
|
157
|
+
heldMutexPath = candidate;
|
|
158
|
+
return true;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (err && err.code === "ENOENT") {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!err || err.code !== "EEXIST") {
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
exists = true;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!exists) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
let data;
|
|
174
|
+
try {
|
|
175
|
+
data = fsApi.readFileSync(candidate, "utf8");
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err && err.code === "ENOENT") {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
const parsed = parseLockData(data);
|
|
183
|
+
if (!parsed) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (isAlive(parsed.pid)) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
candidate = `${candidate}.${parsed.pid}`;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err && err.code === "EEXIST") {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
log(`Takeover mutex error: ${err.message}`);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function dropMutex() {
|
|
202
|
+
const held = heldMutexPath;
|
|
203
|
+
heldMutexPath = null;
|
|
204
|
+
if (!held) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
if (!fsApi.existsSync(held)) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const data = fsApi.readFileSync(held, "utf8");
|
|
212
|
+
const parsed = parseLockData(data);
|
|
213
|
+
if (parsed && parsed.pid === pid) {
|
|
214
|
+
fsApi.unlinkSync(held);
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
log(`Takeover mutex release error: ${err.message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Under the wx mutex: re-read, unlink only the still-expected dead lock,
|
|
223
|
+
* then wx-create. Never rename the live path.
|
|
224
|
+
*/
|
|
225
|
+
function finishAcquireUnderMutex(expectedStale) {
|
|
226
|
+
const current = readLockFile();
|
|
227
|
+
|
|
228
|
+
if (current !== null) {
|
|
229
|
+
const parsed = parseLockData(current);
|
|
230
|
+
if (!parsed) {
|
|
231
|
+
ownsLock = false;
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
if (parsed.pid === pid) {
|
|
235
|
+
return refreshOwned();
|
|
236
|
+
}
|
|
237
|
+
if (isAlive(parsed.pid)) {
|
|
238
|
+
return skipLiveHolder(parsed);
|
|
239
|
+
}
|
|
240
|
+
if (expectedStale === null || current !== expectedStale) {
|
|
241
|
+
ownsLock = false;
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
// Re-read immediately before unlink. Never delete a peer's wx lock.
|
|
245
|
+
const again = readLockFile();
|
|
246
|
+
if (again !== expectedStale) {
|
|
247
|
+
ownsLock = false;
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
fsApi.unlinkSync(lockFile);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (!err || err.code !== "ENOENT") {
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
log(`Removing stale lock file (PID ${parsed ? parsed.pid : "unknown"} not running)`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
fsApi.writeFileSync(lockFile, formatLockData(pid, now()), { flag: "wx" });
|
|
262
|
+
ownsLock = true;
|
|
263
|
+
return true;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if (err.code === "EEXIST") {
|
|
266
|
+
log("Another process acquired lock during race. Skipping indexing.");
|
|
267
|
+
ownsLock = false;
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function acquire() {
|
|
275
|
+
try {
|
|
276
|
+
const lockDir = path.dirname(lockFile);
|
|
277
|
+
if (!fsApi.existsSync(lockDir)) {
|
|
278
|
+
fsApi.mkdirSync(lockDir, { recursive: true });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const existing = readLockFile();
|
|
282
|
+
if (existing !== null) {
|
|
283
|
+
const parsed = parseLockData(existing);
|
|
284
|
+
if (!parsed) {
|
|
285
|
+
ownsLock = false;
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
if (parsed.pid === pid) {
|
|
289
|
+
return refreshOwned();
|
|
290
|
+
}
|
|
291
|
+
if (isAlive(parsed.pid)) {
|
|
292
|
+
return skipLiveHolder(parsed);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!tryAcquireMutex()) {
|
|
297
|
+
log("Another process acquired lock during race. Skipping indexing.");
|
|
298
|
+
ownsLock = false;
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
return finishAcquireUnderMutex(existing);
|
|
303
|
+
} finally {
|
|
304
|
+
dropMutex();
|
|
305
|
+
}
|
|
306
|
+
} catch (e) {
|
|
307
|
+
log(`Lock file error: ${e.message}`);
|
|
308
|
+
ownsLock = false;
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function release() {
|
|
314
|
+
try {
|
|
315
|
+
const lockData = readLockFile();
|
|
316
|
+
if (lockData) {
|
|
317
|
+
const parsed = parseLockData(lockData);
|
|
318
|
+
if (parsed && parsed.pid === pid) {
|
|
319
|
+
fsApi.unlinkSync(lockFile);
|
|
320
|
+
ownsLock = false;
|
|
321
|
+
log(`Released lock file (PID ${pid})`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
log(`Error releasing lock: ${err.message}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function refresh() {
|
|
330
|
+
try {
|
|
331
|
+
if (!ownsLock) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const lockData = readLockFile();
|
|
335
|
+
if (!lockData) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
const parsed = parseLockData(lockData);
|
|
339
|
+
if (parsed && parsed.pid === pid) {
|
|
340
|
+
fsApi.writeFileSync(lockFile, formatLockData(pid, now()));
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
} catch (err) {
|
|
345
|
+
log(`Lock heartbeat error: ${err.message}`);
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function startHeartbeat(intervalMs = DEFAULT_LOCK_HEARTBEAT_MS) {
|
|
351
|
+
if (heartbeatTimer) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
heartbeatTimer = setIntervalFn(() => {
|
|
355
|
+
refresh();
|
|
356
|
+
}, intervalMs);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function stopHeartbeat() {
|
|
360
|
+
if (heartbeatTimer) {
|
|
361
|
+
clearIntervalFn(heartbeatTimer);
|
|
362
|
+
heartbeatTimer = null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
acquire,
|
|
368
|
+
release,
|
|
369
|
+
refresh,
|
|
370
|
+
startHeartbeat,
|
|
371
|
+
stopHeartbeat,
|
|
372
|
+
get ownsLock() {
|
|
373
|
+
return ownsLock;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indexer daemon vs MCP stdio runtime helpers.
|
|
3
|
+
* Kept separate from index.js so daemon paths can be tested without
|
|
4
|
+
* starting MCP stdio or loading the embedding model.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {boolean} indexerMode
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function shouldConnectMcpStdio(indexerMode) {
|
|
12
|
+
return !indexerMode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {boolean} indexerMode
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function shouldExitOnStdinClose(indexerMode) {
|
|
20
|
+
return !indexerMode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Bind stdin `close` → callback only for MCP stdio clients.
|
|
25
|
+
* Indexer daemons must ignore stdin close (LaunchAgent often uses /dev/null).
|
|
26
|
+
*
|
|
27
|
+
* @param {{ on: (event: string, handler: () => void) => void }} stdin
|
|
28
|
+
* @param {boolean} indexerMode
|
|
29
|
+
* @param {() => void} onClose
|
|
30
|
+
* @returns {{ bound: boolean }}
|
|
31
|
+
*/
|
|
32
|
+
export function bindStdinCloseExit(stdin, indexerMode, onClose) {
|
|
33
|
+
if (!shouldExitOnStdinClose(indexerMode)) {
|
|
34
|
+
return { bound: false };
|
|
35
|
+
}
|
|
36
|
+
stdin.on("close", onClose);
|
|
37
|
+
return { bound: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Start one index cycle, or skip if a cycle is already running.
|
|
42
|
+
*
|
|
43
|
+
* @param {boolean} indexingInProgress
|
|
44
|
+
* @param {(msg: string) => void} [log]
|
|
45
|
+
* @returns {{ started: boolean, indexingInProgress: boolean }}
|
|
46
|
+
*/
|
|
47
|
+
export function beginIndexCycle(indexingInProgress, log = (msg) => console.error(msg)) {
|
|
48
|
+
if (indexingInProgress) {
|
|
49
|
+
log("Indexing already in progress, skipping cycle");
|
|
50
|
+
return { started: false, indexingInProgress: true };
|
|
51
|
+
}
|
|
52
|
+
return { started: true, indexingInProgress: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* After a cycle: daemon keeps the lock; MCP local-fallback releases it.
|
|
57
|
+
*
|
|
58
|
+
* @param {{
|
|
59
|
+
* success: boolean,
|
|
60
|
+
* indexerMode: boolean,
|
|
61
|
+
* cycleEndFlags: (success: boolean) => {
|
|
62
|
+
* indexingInProgress: boolean,
|
|
63
|
+
* sessionIndexComplete: boolean,
|
|
64
|
+
* ownsIndexLock: boolean,
|
|
65
|
+
* isFirstEverRun?: boolean
|
|
66
|
+
* },
|
|
67
|
+
* releaseLock: () => void
|
|
68
|
+
* }} args
|
|
69
|
+
*/
|
|
70
|
+
export function applyIndexerCycleEnd({ success, indexerMode, cycleEndFlags, releaseLock }) {
|
|
71
|
+
const flags = cycleEndFlags(success);
|
|
72
|
+
if (indexerMode) {
|
|
73
|
+
return {
|
|
74
|
+
indexingInProgress: flags.indexingInProgress,
|
|
75
|
+
sessionIndexComplete: flags.sessionIndexComplete,
|
|
76
|
+
ownsIndexLock: true,
|
|
77
|
+
isFirstEverRun: flags.isFirstEverRun,
|
|
78
|
+
released: false
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
releaseLock();
|
|
82
|
+
return {
|
|
83
|
+
indexingInProgress: flags.indexingInProgress,
|
|
84
|
+
sessionIndexComplete: flags.sessionIndexComplete,
|
|
85
|
+
ownsIndexLock: flags.ownsIndexLock,
|
|
86
|
+
isFirstEverRun: flags.isFirstEverRun,
|
|
87
|
+
released: true
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* MCP stdio startup: index locally only when the lock is free.
|
|
93
|
+
*
|
|
94
|
+
* @param {() => boolean} acquireLock
|
|
95
|
+
* @returns {{ startBackground: boolean, ownsIndexLock: boolean, startHeartbeat: boolean, reason: "local-fallback" | "lock-held" }}
|
|
96
|
+
*/
|
|
97
|
+
export function mcpIndexingStartup(acquireLock) {
|
|
98
|
+
if (!acquireLock()) {
|
|
99
|
+
return { startBackground: false, ownsIndexLock: false, startHeartbeat: false, reason: "lock-held" };
|
|
100
|
+
}
|
|
101
|
+
return { startBackground: true, ownsIndexLock: true, startHeartbeat: true, reason: "local-fallback" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Shared path once this process owns indexer.lock: heartbeat + background cycles.
|
|
106
|
+
* MCP local-fallback must use this too, not only the daemon.
|
|
107
|
+
*
|
|
108
|
+
* @param {{ startHeartbeat: () => void, startBackground: () => void }} steps
|
|
109
|
+
*/
|
|
110
|
+
export function beginOwnedIndexing(steps) {
|
|
111
|
+
steps.startHeartbeat();
|
|
112
|
+
steps.startBackground();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Retry until the daemon owns indexer.lock, then run onAcquired.
|
|
117
|
+
*
|
|
118
|
+
* @param {() => boolean} acquireLock
|
|
119
|
+
* @param {{
|
|
120
|
+
* retryMs: number,
|
|
121
|
+
* onAcquired: () => void,
|
|
122
|
+
* log?: (msg: string) => void,
|
|
123
|
+
* setTimeoutFn?: typeof setTimeout
|
|
124
|
+
* }} options
|
|
125
|
+
*/
|
|
126
|
+
export function waitForIndexerLock(acquireLock, options) {
|
|
127
|
+
const retryMs = options.retryMs;
|
|
128
|
+
const onAcquired = options.onAcquired;
|
|
129
|
+
const log = options.log || ((msg) => console.error(msg));
|
|
130
|
+
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
|
131
|
+
|
|
132
|
+
const tryAcquire = () => {
|
|
133
|
+
if (acquireLock()) {
|
|
134
|
+
log("Indexer daemon acquired indexer.lock");
|
|
135
|
+
onAcquired();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
log("Indexer daemon waiting for indexer.lock...");
|
|
139
|
+
setTimeoutFn(tryAcquire, retryMs);
|
|
140
|
+
};
|
|
141
|
+
tryAcquire();
|
|
142
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process mode detection for apple-tools-mcp.
|
|
3
|
+
*
|
|
4
|
+
* Canonical indexer entrypoint: `node index.js --mode=indexer`
|
|
5
|
+
* Convenience bin: `apple-tools-indexer` (same file; detected via argv[1]).
|
|
6
|
+
* MCP stdio remains the default when neither is present.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from "path";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string[]} [argv=process.argv]
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function isIndexerMode(argv = process.argv) {
|
|
16
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
if (argv.includes("--mode=indexer")) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
const modeIdx = argv.indexOf("--mode");
|
|
23
|
+
if (modeIdx !== -1 && argv[modeIdx + 1] === "indexer") {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
const entry = argv[1] ? path.basename(argv[1]) : "";
|
|
27
|
+
return entry === "apple-tools-indexer";
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-tools-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "MCP server for semantic search across Apple Mail, Messages, and Calendar",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"apple-tools-mcp": "./index.js"
|
|
8
|
+
"apple-tools-mcp": "./index.js",
|
|
9
|
+
"apple-tools-indexer": "./index.js"
|
|
9
10
|
},
|
|
10
11
|
"author": "Peter Coates",
|
|
11
12
|
"license": "MIT",
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
],
|
|
51
52
|
"scripts": {
|
|
52
53
|
"start": "node index.js",
|
|
54
|
+
"indexer": "node index.js --mode=indexer",
|
|
53
55
|
"build-index": "node -e \"import('./indexer.js').then(i=>i.rebuildIndex()).catch(e=>{console.error(e.message);process.exit(1)})\"",
|
|
54
56
|
"audit": "node scripts/audit-index.js",
|
|
55
57
|
"test": "vitest run",
|