@cachly-dev/mcp-server 0.10.85 → 0.10.86

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/dist/src/index.js CHANGED
@@ -65,7 +65,7 @@ import { Redis } from 'ioredis';
65
65
  let API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
66
66
  let JWT = process.env.CACHLY_JWT ?? '';
67
67
  const _EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
68
- const CURRENT_VERSION = '0.10.85';
68
+ const CURRENT_VERSION = '0.10.86';
69
69
  // Max time to wait for a freshly-provisioned instance to become "running" before
70
70
  // giving up. Free-tier provisioning in high-latency regions can take 45–90s, so the
71
71
  // old hard-coded 25s caused spurious "instance_not_reachable" on first run.
@@ -77,35 +77,92 @@ const PROVISION_TIMEOUT_MS = Number(process.env.CACHLY_PROVISION_TIMEOUT_MS ?? 9
77
77
  let _defaultInstanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
78
78
  // Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
79
79
  let _defaultInstanceLastAttempt = 0;
80
+ // In-flight resolution guard: a burst of parallel tool calls on startup must not
81
+ // each list-and-provision (which would race and create duplicate instances).
82
+ let _resolveInFlight = null;
83
+ /**
84
+ * Auto-provision a Brain instance via the idempotent find-or-create endpoint.
85
+ * Returns the instance id, or '' on failure. Fires auto_provision_failed telemetry
86
+ * on a non-2xx response so the activation funnel can spot free-tier breakage.
87
+ */
88
+ async function autoProvisionInstance() {
89
+ if (!JWT)
90
+ return '';
91
+ try {
92
+ const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
93
+ method: 'POST',
94
+ headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
95
+ signal: AbortSignal.timeout(12000),
96
+ });
97
+ if (autoRes.ok) {
98
+ const body = await autoRes.json();
99
+ const id = body.instance?.id ?? body.instance_id;
100
+ if (id)
101
+ return id;
102
+ }
103
+ else {
104
+ void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${JWT}` },
107
+ body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
108
+ signal: AbortSignal.timeout(3000),
109
+ }).catch(() => { });
110
+ }
111
+ }
112
+ catch { /* non-fatal — server unreachable */ }
113
+ return '';
114
+ }
80
115
  async function resolveDefaultInstanceId() {
81
116
  if (_defaultInstanceId)
82
117
  return _defaultInstanceId;
83
118
  if (!JWT)
84
119
  return '';
120
+ // Coalesce concurrent resolutions so parallel tool calls share one round-trip.
121
+ if (_resolveInFlight)
122
+ return _resolveInFlight;
85
123
  // Cooldown: don't hammer the API on every tool call after a transient failure.
86
124
  const now = Date.now();
87
125
  if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000)
88
126
  return '';
89
127
  _defaultInstanceLastAttempt = now;
90
- try {
91
- const res = await fetch(`${API_URL}/api/v1/instances`, {
92
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
93
- signal: AbortSignal.timeout(5000),
94
- });
95
- if (!res.ok)
96
- return '';
97
- const data = await res.json();
98
- const instances = (data?.data ?? []);
99
- // Prefer running, fall back to provisioning so the ID is resolved even during startup.
100
- const best = instances.find(i => i.status === 'running') ?? instances.find(i => i.status === 'provisioning');
101
- if (best) {
102
- _defaultInstanceId = best.id;
103
- _defaultInstanceLastAttempt = 0;
104
- return _defaultInstanceId;
128
+ _resolveInFlight = (async () => {
129
+ try {
130
+ const res = await fetch(`${API_URL}/api/v1/instances`, {
131
+ headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
132
+ signal: AbortSignal.timeout(5000),
133
+ });
134
+ if (res.ok) {
135
+ const data = await res.json();
136
+ const instances = (data?.data ?? []);
137
+ // Prefer running, fall back to provisioning so the ID resolves even during startup.
138
+ const best = instances.find(i => i.status === 'running') ?? instances.find(i => i.status === 'provisioning');
139
+ if (best) {
140
+ _defaultInstanceId = best.id;
141
+ _defaultInstanceLastAttempt = 0;
142
+ return _defaultInstanceId;
143
+ }
144
+ // Authenticated but zero instances → auto-provision so the user is never
145
+ // stuck. This self-heals the silent failure mode where device-flow
146
+ // provisioning failed once and every later tool call then ran with an
147
+ // empty instance_id (no recalls, never counted as an active instance).
148
+ const provisioned = await autoProvisionInstance();
149
+ if (provisioned) {
150
+ _defaultInstanceId = provisioned;
151
+ _defaultInstanceLastAttempt = 0;
152
+ void persistInstanceIdToConfig(provisioned);
153
+ return _defaultInstanceId;
154
+ }
155
+ }
105
156
  }
157
+ catch { /* transient error — will retry after cooldown */ }
158
+ return '';
159
+ })();
160
+ try {
161
+ return await _resolveInFlight;
162
+ }
163
+ finally {
164
+ _resolveInFlight = null;
106
165
  }
107
- catch { /* transient error — will retry after cooldown */ }
108
- return '';
109
166
  }
110
167
  // ── Self-Healing Auth ─────────────────────────────────────────────────────────
111
168
  // Prevents the silent "0 recalls because the token quietly died" failure mode.
@@ -151,6 +208,39 @@ async function persistApiKeyToConfig(apiKey) {
151
208
  }
152
209
  catch { /* non-critical — never break a tool call on a filesystem error */ }
153
210
  }
211
+ /**
212
+ * Persist the resolved instance id to ~/.claude/mcp.json so restarts reuse it
213
+ * (avoids a list/provision round-trip on every startup). Only updates an
214
+ * existing cachly entry — does not create config from scratch.
215
+ */
216
+ async function persistInstanceIdToConfig(instanceId) {
217
+ if (!instanceId)
218
+ return;
219
+ try {
220
+ const { writeFile, readFile } = await import('node:fs/promises');
221
+ const { existsSync } = await import('node:fs');
222
+ const { resolve } = await import('node:path');
223
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
224
+ if (!home)
225
+ return;
226
+ const configPath = resolve(home, '.claude', 'mcp.json');
227
+ if (!existsSync(configPath))
228
+ return;
229
+ let cfg = {};
230
+ try {
231
+ cfg = JSON.parse(await readFile(configPath, 'utf-8'));
232
+ }
233
+ catch {
234
+ return;
235
+ }
236
+ const entry = cfg.mcpServers?.['cachly'];
237
+ if (entry?.env) {
238
+ entry.env['CACHLY_BRAIN_INSTANCE_ID'] = instanceId;
239
+ await writeFile(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
240
+ }
241
+ }
242
+ catch { /* non-critical */ }
243
+ }
154
244
  /**
155
245
  * Exchange the current (still-valid) token for a fresh long-lived API key.
156
246
  * Returns true on success. Used by self-healing when a token is near expiry.
@@ -312,67 +402,13 @@ async function pollDeviceFlow(flow) {
312
402
  // Persist the API key to ~/.claude/mcp.json so subsequent MCP server restarts
313
403
  // keep the token. Without this, JWT is lost on restart and brain_recall_count
314
404
  // is never incremented (telemetry has no api_key → no tenant resolution).
315
- void (async () => {
316
- try {
317
- const { writeFile, mkdir, readFile } = await import('node:fs/promises');
318
- const { existsSync } = await import('node:fs');
319
- const { resolve, dirname } = await import('node:path');
320
- const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
321
- if (!home)
322
- return;
323
- const configPath = resolve(home, '.claude', 'mcp.json');
324
- let cfg = {};
325
- if (existsSync(configPath)) {
326
- try {
327
- cfg = JSON.parse(await readFile(configPath, 'utf-8'));
328
- }
329
- catch { /* corrupt — start fresh */ }
330
- }
331
- cfg.mcpServers ??= {};
332
- const existing = cfg.mcpServers['cachly'];
333
- if (existing) {
334
- existing.env ??= {};
335
- existing.env['CACHLY_JWT'] = apiKey;
336
- }
337
- else {
338
- cfg.mcpServers['cachly'] = {
339
- command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'],
340
- env: { CACHLY_JWT: apiKey },
341
- };
342
- }
343
- await mkdir(dirname(configPath), { recursive: true });
344
- await writeFile(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
345
- }
346
- catch { /* non-critical — don't break the flow on filesystem errors */ }
347
- })();
348
- // Auto-provision: find or create the user's brain instance.
405
+ void persistApiKeyToConfig(apiKey);
406
+ // Auto-provision: find or create the user's brain instance. resolveDefaultInstanceId
407
+ // now lists existing instances and auto-provisions when the account has none,
408
+ // so a single call covers both paths (and self-heals on every later tool call
409
+ // if provisioning is briefly unavailable here).
349
410
  _defaultInstanceLastAttempt = 0;
350
411
  await resolveDefaultInstanceId();
351
- if (!_defaultInstanceId) {
352
- try {
353
- const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
354
- method: 'POST',
355
- headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
356
- signal: AbortSignal.timeout(12000),
357
- });
358
- if (autoRes.ok) {
359
- const body = await autoRes.json();
360
- const id = body.instance?.id ?? body.instance_id;
361
- if (id)
362
- _defaultInstanceId = id;
363
- }
364
- else {
365
- // Log so telemetry can detect free-tier auto-provision failures
366
- void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
367
- method: 'POST',
368
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${JWT}` },
369
- body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
370
- signal: AbortSignal.timeout(3000),
371
- }).catch(() => { });
372
- }
373
- }
374
- catch { /* non-fatal — server unreachable, getConnection will surface the error */ }
375
- }
376
412
  // Give a provisioning instance a head-start so getConnection's wait is shorter.
377
413
  // The instance is almost always ready by the time the user's tool re-enters.
378
414
  if (_defaultInstanceId) {
@@ -405,37 +441,10 @@ async function pollDeviceFlow(flow) {
405
441
  }
406
442
  catch { /* non-fatal — getConnection will handle the wait if still provisioning */ }
407
443
  }
408
- // Persist instance_id to ~/.claude/mcp.json after it's known.
409
- // Without this, every restart loses CACHLY_BRAIN_INSTANCE_ID → resolveDefaultInstanceId
410
- // makes an extra API call on every startup (with 30s cooldown on failure).
411
- if (_defaultInstanceId) {
412
- void (async () => {
413
- try {
414
- const { writeFile, readFile } = await import('node:fs/promises');
415
- const { existsSync } = await import('node:fs');
416
- const { resolve } = await import('node:path');
417
- const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
418
- if (!home)
419
- return;
420
- const configPath = resolve(home, '.claude', 'mcp.json');
421
- if (!existsSync(configPath))
422
- return;
423
- let cfg = {};
424
- try {
425
- cfg = JSON.parse(await readFile(configPath, 'utf-8'));
426
- }
427
- catch {
428
- return;
429
- }
430
- const entry = cfg.mcpServers?.['cachly'];
431
- if (entry?.env) {
432
- entry.env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
433
- await writeFile(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
434
- }
435
- }
436
- catch { /* non-critical */ }
437
- })();
438
- }
444
+ // Persist instance_id to ~/.claude/mcp.json after it's known, so every
445
+ // restart reuses it instead of making an extra list call on startup.
446
+ if (_defaultInstanceId)
447
+ void persistInstanceIdToConfig(_defaultInstanceId);
439
448
  return 'done';
440
449
  }
441
450
  if (data.error === 'slow_down')
@@ -457,10 +466,16 @@ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp,
457
466
  const pool = new Map();
458
467
  async function getConnection(instance_id) {
459
468
  if (!instance_id) {
460
- throw new McpError(ErrorCode.InvalidRequest, 'No instance_id provided and no running instance could be resolved automatically.\n\n' +
469
+ // With auto-provisioning, this only happens when the API was briefly
470
+ // unreachable during resolution. The next tool call self-heals, so lead
471
+ // with "retry" rather than asking the user to configure anything.
472
+ throw new McpError(ErrorCode.InvalidRequest, '⏳ Your Brain instance is still being set up.\n\n' +
473
+ 'This usually resolves in a few seconds — just call the tool again and it ' +
474
+ 'will connect automatically.\n\n' +
475
+ 'If it keeps happening:\n' +
476
+ '• Run `list_instances` to check your instances, or\n' +
461
477
  '• Set CACHLY_BRAIN_INSTANCE_ID in your MCP config, or\n' +
462
- '• Pass instance_id explicitly to the tool, or\n' +
463
- '• Run `list_instances` to see your available instances.');
478
+ '• Reach us at support@cachly.dev.');
464
479
  }
465
480
  if (pool.has(instance_id))
466
481
  return pool.get(instance_id);
@@ -732,7 +747,7 @@ async function handleTool(name, args) {
732
747
  '',
733
748
  'After sign-in: call **any tool again** — your Brain activates instantly.',
734
749
  '',
735
- '✨ Free forever · No credit card · 107 MCP tools · GDPR · EU servers',
750
+ '✨ Free forever · No credit card · 114 MCP tools · GDPR · EU servers',
736
751
  ].join('\n');
737
752
  }
738
753
  // Device flow unavailable (network issue) — fall back to manual setup