apple-tools-mcp 1.1.4 → 1.2.1

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/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
+ }
package/lib/indexGate.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * index cycle." A second MCP instance that lost the indexer lock never runs
6
6
  * a cycle, so that flag stays false. Lost-lock must not be treated as
7
7
  * "still indexing" — callers still check isIndexReady() for a missing index.
8
+ * isIndexReady() / initDB() must re-list LanceDB tables on each check so a
9
+ * first empty catalog (daemon writer in flight) is not cached forever.
8
10
  */
9
11
 
10
12
  /**
@@ -57,3 +59,50 @@ export function indexUnavailableMessage(type) {
57
59
  }
58
60
  return "Index not available. Please try again shortly.";
59
61
  }
62
+
63
+ export const BUILDING_INITIAL_INDEX_MESSAGE =
64
+ "Building initial index. This may take several minutes on first run. Please try again shortly.";
65
+
66
+ export const INDEXING_NEW_DATA_MESSAGE =
67
+ "Indexing new data. Please try again in a moment.";
68
+
69
+ /**
70
+ * Still-indexing copy. Only for a process that owns the lock and has not
71
+ * finished a local cycle — never for a lost-lock MCP reader.
72
+ *
73
+ * @param {boolean} isFirstEverRun
74
+ * @returns {string}
75
+ */
76
+ export function indexingInProgressMessage(isFirstEverRun) {
77
+ return isFirstEverRun ? BUILDING_INITIAL_INDEX_MESSAGE : INDEXING_NEW_DATA_MESSAGE;
78
+ }
79
+
80
+ /**
81
+ * Preflight for index-backed MCP query tools.
82
+ * Readiness is `indexReady` (usable on-disk tables), not sessionIndexComplete.
83
+ * Lost-lock never returns "building initial index" / still-indexing.
84
+ *
85
+ * @param {{
86
+ * sessionIndexComplete: boolean,
87
+ * ownsIndexLock: boolean,
88
+ * indexReady: boolean,
89
+ * type?: "emails"|"messages"|"calendar",
90
+ * isFirstEverRun?: boolean
91
+ * }} args
92
+ * @returns {{ ok: boolean, message: string|null }}
93
+ */
94
+ export function indexQueryGate({
95
+ sessionIndexComplete,
96
+ ownsIndexLock,
97
+ indexReady,
98
+ type,
99
+ isFirstEverRun = false
100
+ }) {
101
+ if (isSearchBlockedByIndexing(sessionIndexComplete, ownsIndexLock)) {
102
+ return { ok: false, message: indexingInProgressMessage(isFirstEverRun) };
103
+ }
104
+ if (!indexReady) {
105
+ return { ok: false, message: indexUnavailableMessage(type) };
106
+ }
107
+ return { ok: true, message: null };
108
+ }