@cachly-dev/mcp-server 0.10.5 → 0.10.6

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.
Files changed (50) hide show
  1. package/.github/workflows/publish.yml +45 -0
  2. package/LAUNCH_TEXTS.md +182 -0
  3. package/LICENSE +202 -0
  4. package/dist/__tests__/auth.test.d.ts +1 -0
  5. package/dist/__tests__/auth.test.js +107 -0
  6. package/dist/__tests__/auth.test.js.map +1 -0
  7. package/dist/auth.d.ts +3 -0
  8. package/dist/auth.js +32 -0
  9. package/dist/auth.js.map +1 -0
  10. package/dist/handlers/advanced.js +38 -31
  11. package/dist/handlers/advanced.js.map +1 -1
  12. package/dist/handlers/brain.js +144 -50
  13. package/dist/handlers/brain.js.map +1 -1
  14. package/dist/handlers/fedbrain.js +55 -38
  15. package/dist/handlers/fedbrain.js.map +1 -1
  16. package/dist/handlers/roadmap.js +8 -2
  17. package/dist/handlers/roadmap.js.map +1 -1
  18. package/dist/handlers/syndicate.js +15 -7
  19. package/dist/handlers/syndicate.js.map +1 -1
  20. package/dist/handlers/team.js +62 -50
  21. package/dist/handlers/team.js.map +1 -1
  22. package/dist/index.js +222 -217
  23. package/dist/index.js.map +1 -1
  24. package/dist/tools.d.ts +0 -8
  25. package/dist/tools.js +5 -15
  26. package/dist/tools.js.map +1 -1
  27. package/dist/utils.d.ts +1 -0
  28. package/dist/utils.js +13 -0
  29. package/dist/utils.js.map +1 -0
  30. package/glama.json +29 -0
  31. package/llms.txt +155 -0
  32. package/package.json +29 -13
  33. package/scripts/postinstall.js +12 -19
  34. package/server.json +3 -9
  35. package/smithery.yaml +6 -8
  36. package/src/__tests__/auth.test.ts +147 -0
  37. package/src/auth.ts +46 -0
  38. package/src/handlers/advanced.ts +37 -23
  39. package/src/handlers/brain.ts +141 -47
  40. package/src/handlers/fedbrain.ts +43 -31
  41. package/src/handlers/roadmap.ts +7 -2
  42. package/src/handlers/syndicate.ts +13 -7
  43. package/src/handlers/team.ts +57 -33
  44. package/src/index.ts +224 -234
  45. package/src/tools.ts +5 -16
  46. package/src/utils.ts +10 -0
  47. package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
  48. package/dist/__tests__/brain-predict-failures.test.js +0 -339
  49. package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
  50. package/src/__tests__/brain-predict-failures.test.ts +0 -430
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { execSync } from 'node:child_process';
4
5
  import { join } from 'node:path';
@@ -52,19 +53,22 @@ import { Redis } from 'ioredis';
52
53
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
53
54
  let JWT = process.env.CACHLY_JWT ?? '';
54
55
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
55
- const CURRENT_VERSION = '0.10.4';
56
+ const CURRENT_VERSION = '0.10.0';
56
57
 
57
58
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
58
59
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
59
60
  // When neither is set, we auto-fetch the first running instance once per process.
60
61
  let _defaultInstanceId: string = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
61
- let _defaultInstanceFetched = false;
62
+ // Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
63
+ let _defaultInstanceLastAttempt = 0;
62
64
 
63
65
  async function resolveDefaultInstanceId(): Promise<string> {
64
66
  if (_defaultInstanceId) return _defaultInstanceId;
65
- if (_defaultInstanceFetched) return '';
66
- _defaultInstanceFetched = true;
67
67
  if (!JWT) return '';
68
+ // Cooldown: don't hammer the API on every tool call after a transient failure.
69
+ const now = Date.now();
70
+ if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000) return '';
71
+ _defaultInstanceLastAttempt = now;
68
72
  try {
69
73
  const res = await fetch(`${API_URL}/api/v1/instances`, {
70
74
  headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
@@ -72,9 +76,15 @@ async function resolveDefaultInstanceId(): Promise<string> {
72
76
  });
73
77
  if (!res.ok) return '';
74
78
  const data = await res.json() as { data?: Array<{ id: string; status: string }> };
75
- const running = (data?.data ?? []).filter(i => i.status === 'running');
76
- if (running.length > 0) { _defaultInstanceId = running[0].id; return _defaultInstanceId; }
77
- } catch { /* non-fatal — caller will surface missing instance_id error */ }
79
+ const instances = (data?.data ?? []) as Array<{ id: string; status: string }>;
80
+ // Prefer running, fall back to provisioning so the ID is resolved even during startup.
81
+ const best = instances.find(i => i.status === 'running') ?? instances.find(i => i.status === 'provisioning');
82
+ if (best) {
83
+ _defaultInstanceId = best.id;
84
+ _defaultInstanceLastAttempt = 0;
85
+ return _defaultInstanceId;
86
+ }
87
+ } catch { /* transient error — will retry after cooldown */ }
78
88
  return '';
79
89
  }
80
90
 
@@ -149,24 +159,55 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
149
159
  JWT = apiKey;
150
160
  setEmbedJwt(apiKey); // keep embeddings.ts in sync
151
161
  _deviceFlow = null;
152
- // Auto-provision instance
153
- _defaultInstanceFetched = false;
162
+ // Auto-provision: find or create the user's brain instance.
163
+ _defaultInstanceLastAttempt = 0;
154
164
  await resolveDefaultInstanceId();
155
165
  if (!_defaultInstanceId) {
156
- // Try auto-provision
157
166
  try {
158
167
  const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
159
168
  method: 'POST',
160
169
  headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
161
- signal: AbortSignal.timeout(10000),
170
+ signal: AbortSignal.timeout(12000),
162
171
  });
163
172
  if (autoRes.ok) {
164
- const body = await autoRes.json() as { instance?: { id: string }; instance_id?: string };
173
+ const body = await autoRes.json() as {
174
+ instance?: { id: string; status?: string };
175
+ instance_id?: string;
176
+ status?: string;
177
+ };
165
178
  const id = body.instance?.id ?? body.instance_id;
166
179
  if (id) _defaultInstanceId = id;
167
180
  }
168
181
  } catch { /* non-fatal */ }
169
182
  }
183
+ // Give a provisioning instance a head-start so getConnection's wait is shorter.
184
+ // The instance is almost always ready by the time the user's tool re-enters.
185
+ if (_defaultInstanceId) {
186
+ try {
187
+ const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
188
+ headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
189
+ signal: AbortSignal.timeout(4000),
190
+ });
191
+ if (checkRes.ok) {
192
+ const checkInst = await checkRes.json() as { status?: string };
193
+ if (checkInst.status === 'provisioning') {
194
+ // Wait up to 20s here so the re-entered tool call lands on a running instance.
195
+ const pDeadline = Date.now() + 20_000;
196
+ while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
197
+ await new Promise(r => setTimeout(r, 3000));
198
+ const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
199
+ headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
200
+ signal: AbortSignal.timeout(4000),
201
+ }).catch(() => null);
202
+ if (rr?.ok) {
203
+ const ri = await rr.json() as { status?: string };
204
+ (checkInst as { status?: string }).status = ri.status;
205
+ } else { break; }
206
+ }
207
+ }
208
+ }
209
+ } catch { /* non-fatal — getConnection will handle the wait if still provisioning */ }
210
+ }
170
211
  return 'done';
171
212
  }
172
213
  if (data.error === 'slow_down') flow.pollInterval = Math.min(flow.pollInterval + 2000, 15000);
@@ -216,18 +257,48 @@ interface SemanticSearchResponse {
216
257
  const pool = new Map<string, Redis>();
217
258
 
218
259
  async function getConnection(instance_id: string): Promise<Redis> {
219
- if (pool.has(instance_id)) return pool.get(instance_id)!;
220
-
221
- const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
222
- if (inst.status !== 'running') {
260
+ if (!instance_id) {
223
261
  throw new McpError(
224
262
  ErrorCode.InvalidRequest,
225
- `Instance "${inst.name}" is not running (status: ${inst.status}). ` +
226
- `It may still be provisioning or awaiting payment.`
263
+ 'No instance_id provided and no running instance could be resolved automatically.\n\n' +
264
+ '• Set CACHLY_BRAIN_INSTANCE_ID in your MCP config, or\n' +
265
+ '• Pass instance_id explicitly to the tool, or\n' +
266
+ '• Run `list_instances` to see your available instances.'
227
267
  );
228
268
  }
269
+
270
+ if (pool.has(instance_id)) return pool.get(instance_id)!;
271
+
272
+ // Fetch instance, waiting up to 25 s if it is still provisioning.
273
+ // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
274
+ // all happen in quick succession and the instance isn't running yet.
275
+ let inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
276
+ if (inst.status === 'provisioning') {
277
+ const deadline = Date.now() + 25_000;
278
+ while (inst.status === 'provisioning' && Date.now() < deadline) {
279
+ await new Promise(r => setTimeout(r, 3000));
280
+ inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
281
+ }
282
+ }
283
+
284
+ if (inst.status !== 'running') {
285
+ // Telemetry: unreachable instance so the team can proactively investigate
286
+ void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
287
+ method: 'POST',
288
+ headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
289
+ body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
290
+ signal: AbortSignal.timeout(3000),
291
+ }).catch(() => {});
292
+ const hint = inst.status === 'provisioning'
293
+ ? `⏳ Brain instance "${inst.name}" is still starting up.\n\nThis usually finishes within 30 seconds. Please try again in a moment.`
294
+ : `Brain instance "${inst.name}" is not reachable (status: ${inst.status}).\n\n` +
295
+ `• View your instance at: https://cachly.dev/instances\n` +
296
+ `• Run \`get_api_status\` for a full diagnostic.`;
297
+ throw new McpError(ErrorCode.InvalidRequest, hint);
298
+ }
229
299
  if (!inst.host || !inst.port) {
230
- throw new McpError(ErrorCode.InternalError, `Instance "${inst.name}" has no host/port yet.`);
300
+ throw new McpError(ErrorCode.InternalError,
301
+ `Instance "${inst.name}" is running but has no host/port — contact support@cachly.dev.`);
231
302
  }
232
303
 
233
304
  // Fetch connection details (includes password) from the dedicated endpoint.
@@ -240,7 +311,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
240
311
  password = conn.password ?? undefined;
241
312
  tlsEnabled = conn.tls_enabled !== false;
242
313
  } catch {
243
- // Fallback: no password, use TLS default from instance
314
+ // Fallback: use TLS flag from instance metadata
244
315
  }
245
316
 
246
317
  const client = new Redis({
@@ -248,26 +319,33 @@ async function getConnection(instance_id: string): Promise<Redis> {
248
319
  port: inst.port,
249
320
  password: password || undefined,
250
321
  ...(tlsEnabled ? { tls: {} } : {}),
251
- lazyConnect: true, // don't auto-connect — we connect explicitly below
322
+ lazyConnect: true,
252
323
  enableReadyCheck: true,
253
- connectTimeout: 5000,
324
+ connectTimeout: 8000,
254
325
  retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
255
326
  });
256
327
 
257
- client.on('error', () => {
258
- pool.delete(instance_id); // remove stale connection on error
259
- });
328
+ const evict = () => pool.delete(instance_id);
329
+ client.on('error', evict);
330
+ client.on('end', evict);
260
331
 
261
- // Connect explicitly so we can catch connection errors as a proper rejected
262
- // Promise instead of an unhandled 'error' event that would kill the process.
263
332
  try {
264
333
  await client.connect();
265
334
  } catch (err: unknown) {
266
335
  client.disconnect();
267
336
  const msg = err instanceof Error ? err.message : String(err);
337
+ void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
338
+ method: 'POST',
339
+ headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
340
+ body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
341
+ signal: AbortSignal.timeout(3000),
342
+ }).catch(() => {});
268
343
  throw new McpError(
269
344
  ErrorCode.InternalError,
270
- `Could not connect to instance "${inst.name}" (${inst.host}:${inst.port}): ${msg}`
345
+ `Could not reach Brain instance "${inst.name}" (${inst.host}:${inst.port}).\n\n` +
346
+ `• Check your network connection\n` +
347
+ `• Run \`get_api_status\` to verify instance status\n` +
348
+ `• Technical detail: ${msg}`
271
349
  );
272
350
  }
273
351
 
@@ -277,33 +355,8 @@ async function getConnection(instance_id: string): Promise<Redis> {
277
355
 
278
356
  // ── API helper ────────────────────────────────────────────────────────────────
279
357
 
280
- function jwtExpiryMs(token: string): number | null {
281
- try {
282
- const parts = token.split('.');
283
- if (parts.length !== 3) return null;
284
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as { exp?: number };
285
- return payload.exp ? payload.exp * 1000 : null;
286
- } catch { return null; }
287
- }
288
-
289
358
  async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
290
- if (!JWT) {
291
- throw new McpError(
292
- ErrorCode.InvalidRequest,
293
- 'CACHLY_JWT env var not set.\n\nGet your token at https://cachly.dev/setup-ai and add it to your MCP config:\n CACHLY_JWT=<your-token>'
294
- );
295
- }
296
-
297
- // Detect token expiry before making the network call — gives a clearer error
298
- const expMs = jwtExpiryMs(JWT);
299
- if (expMs !== null && expMs < Date.now()) {
300
- const expiredAt = new Date(expMs).toISOString();
301
- throw new McpError(
302
- ErrorCode.InvalidRequest,
303
- `CACHLY_JWT expired at ${expiredAt}.\n\nGet a fresh token at https://cachly.dev/setup-ai and update CACHLY_JWT in your MCP config.`
304
- );
305
- }
306
-
359
+ checkJwt(JWT);
307
360
  const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
308
361
  const res = await fetch(`${API_URL}${path}`, {
309
362
  ...options,
@@ -317,22 +370,7 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
317
370
  if (!res.ok) {
318
371
  const body = await res.json().catch(() => ({ error: res.statusText }));
319
372
  const detail = (body as { error?: string }).error ?? res.statusText;
320
- if (res.status === 401) {
321
- throw new McpError(
322
- ErrorCode.InvalidRequest,
323
- `Authentication failed (401): ${detail}\n\nYour CACHLY_JWT may be expired or invalid. Get a fresh token at https://cachly.dev/setup-ai`
324
- );
325
- }
326
- if (res.status === 403) {
327
- throw new McpError(
328
- ErrorCode.InvalidRequest,
329
- `Access denied (403): ${detail}\n\nCheck that your CACHLY_JWT belongs to an account with access to this resource.`
330
- );
331
- }
332
- throw new McpError(
333
- ErrorCode.InternalError,
334
- `cachly API error ${res.status}: ${detail}`
335
- );
373
+ handleApiError(res.status, detail);
336
374
  }
337
375
  if (res.status === 204) return undefined as T;
338
376
  return res.json() as Promise<T>;
@@ -360,47 +398,6 @@ import { TOOLS } from './tools.js';
360
398
 
361
399
  // ── Handlers ──────────────────────────────────────────────────────────────────
362
400
 
363
- // ── Tool-call analytics (opt-out: CACHLY_NO_TELEMETRY=1) ────────────────────
364
- // In-memory counters — exposed via /stats on HTTP mode
365
- const _toolCallCounts: Record<string, number> = {};
366
- let _toolCallTotal = 0;
367
- let _toolCallAuthedTotal = 0;
368
-
369
- async function trackToolCall(toolName: string, authed: boolean, durationMs: number): Promise<void> {
370
- if (process.env.CACHLY_NO_TELEMETRY === '1') return;
371
- // Increment in-memory counters
372
- _toolCallTotal++;
373
- if (authed) _toolCallAuthedTotal++;
374
- _toolCallCounts[toolName] = (_toolCallCounts[toolName] ?? 0) + 1;
375
- // Structured JSON to stdout — searchable via: docker logs cachly-mcp-http-1 | jq
376
- process.stdout.write(JSON.stringify({
377
- ts: new Date().toISOString(),
378
- event: 'tool_call',
379
- tool: toolName,
380
- authed,
381
- ms: durationMs,
382
- v: CURRENT_VERSION,
383
- }) + '\n');
384
- // Plausible custom event (fire-and-forget, never blocks user)
385
- try {
386
- await fetch('https://analytics.cachly.dev/api/event', {
387
- method: 'POST',
388
- headers: {
389
- 'Content-Type': 'application/json',
390
- 'User-Agent': `cachly-mcp/${CURRENT_VERSION}`,
391
- 'X-Forwarded-For': '127.0.0.1',
392
- },
393
- body: JSON.stringify({
394
- domain: 'mcp.cachly.dev',
395
- name: 'tool_call',
396
- url: `https://mcp.cachly.dev/mcp/${toolName}`,
397
- props: { tool: toolName, authed: String(authed) },
398
- }),
399
- signal: AbortSignal.timeout(3000),
400
- });
401
- } catch { /* fire-and-forget */ }
402
- }
403
-
404
401
  // Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
405
402
  let _telemetryPingSent = false;
406
403
  async function sendAnonymousTelemetry(toolName: string): Promise<void> {
@@ -424,61 +421,6 @@ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
424
421
  }
425
422
 
426
423
  async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
427
- // ── sign_in: explicit device-flow trigger — works even without JWT ────────
428
- if (name === 'sign_in') {
429
- if (JWT) {
430
- return '✅ **Already signed in.** Your cachly AI Brain is ready — all 63 tools available.';
431
- }
432
- // Poll if flow already in progress
433
- if (_deviceFlow) {
434
- const result = await pollDeviceFlow(_deviceFlow);
435
- if (result === 'done') {
436
- return [
437
- '✅ **Sign-in complete!** Your cachly AI Brain is ready.',
438
- '',
439
- 'All 63 brain tools are now available. Start with `session_start` to load context from your last session.',
440
- ].join('\n');
441
- }
442
- if (result === 'expired') {
443
- _deviceFlow = null;
444
- // fall through to start a new flow
445
- } else {
446
- return [
447
- '⏳ **Waiting for you to approve in the browser...**',
448
- '',
449
- `Sign in at: **${_deviceFlow.verifyUrl}**`,
450
- `Code: **${_deviceFlow.userCode}**`,
451
- '',
452
- 'After approving, call `sign_in` again — it will complete automatically.',
453
- ].join('\n');
454
- }
455
- }
456
- const flow = await startDeviceFlow();
457
- if (flow) {
458
- _deviceFlow = flow;
459
- return [
460
- '🧠 **cachly AI Brain — One-click sign in**',
461
- '',
462
- '1. Open this URL (it may open automatically):',
463
- ` **${flow.verifyUrl}**`,
464
- '',
465
- `2. If prompted for a code, enter: **${flow.userCode}**`,
466
- '',
467
- '3. After approving in your browser, call `sign_in` again — it will complete automatically.',
468
- '',
469
- '✨ Free tier: 1 Brain instance · persistent memory · 63 MCP tools · no credit card required.',
470
- ].join('\n');
471
- }
472
- return [
473
- '🧠 **cachly AI Brain — Setup required**',
474
- '',
475
- 'Run once in your terminal:',
476
- ' `npx @cachly-dev/mcp-server@latest setup`',
477
- '',
478
- 'Or sign up at: https://cachly.dev/setup-ai',
479
- ].join('\n');
480
- }
481
-
482
424
  // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
483
425
  if (!JWT) {
484
426
  void sendAnonymousTelemetry(name);
@@ -541,10 +483,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
541
483
  ].join('\n');
542
484
  }
543
485
 
544
- // Track authenticated tool call (fire-and-forget)
545
- const _trackStart = Date.now();
546
- void (async () => { await trackToolCall(name, true, Date.now() - _trackStart); })();
547
-
548
486
  // Auto-resolve instance_id from env / API when not provided in args
549
487
  if (!args.instance_id) {
550
488
  const defaultId = await resolveDefaultInstanceId();
@@ -637,6 +575,44 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
637
575
  authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
638
576
  }
639
577
 
578
+ // List instances + connectivity
579
+ let instanceInfo = '';
580
+ try {
581
+ const listRes = await apiFetch<{ data: Instance[] }>('/api/v1/instances');
582
+ const instances = listRes.data ?? [];
583
+ if (instances.length === 0) {
584
+ instanceInfo = '\n\n🧠 **Brain Instances:** none — create one at https://cachly.dev/instances';
585
+ } else {
586
+ const lines: string[] = ['\n\n🧠 **Brain Instances:**'];
587
+ for (const inst of instances) {
588
+ const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
589
+ const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
590
+ lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
591
+ if (inst.status === 'running' && inst.host) {
592
+ // Try a quick ping on the cached connection
593
+ const pooled = pool.get(inst.id);
594
+ if (pooled) {
595
+ try {
596
+ await pooled.ping();
597
+ lines.push(` 💓 Redis: connected`);
598
+ } catch {
599
+ lines.push(` ⚠️ Redis: pooled but ping failed — will reconnect on next use`);
600
+ }
601
+ } else {
602
+ lines.push(` 💤 Redis: not connected yet (connects on first tool use)`);
603
+ }
604
+ } else if (inst.status === 'provisioning') {
605
+ lines.push(` ⏳ Still provisioning — check back in ~30 s`);
606
+ } else if (inst.status !== 'running') {
607
+ lines.push(` ❌ Not reachable (status: ${inst.status})`);
608
+ }
609
+ }
610
+ instanceInfo = lines.join('\n');
611
+ }
612
+ } catch (e) {
613
+ instanceInfo = `\n\n🧠 **Brain Instances:** could not fetch — ${(e as Error).message}`;
614
+ }
615
+
640
616
  return [
641
617
  `📡 **cachly API Status**`,
642
618
  ``,
@@ -645,6 +621,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
645
621
  ``,
646
622
  `🔑 **Auth:**`,
647
623
  authInfo,
624
+ instanceInfo,
648
625
  ].join('\n');
649
626
  }
650
627
 
@@ -658,15 +635,7 @@ const server = new Server(
658
635
  { capabilities: { tools: {} } }
659
636
  );
660
637
 
661
- server.setRequestHandler(ListToolsRequestSchema, async () => {
662
- if (!JWT) {
663
- // Surface sign_in as the only prominent tool when not authenticated,
664
- // so AI assistants proactively call it before attempting any brain tool.
665
- const signInTool = TOOLS.find(t => t.name === 'sign_in');
666
- return { tools: signInTool ? [signInTool] : TOOLS };
667
- }
668
- return { tools: TOOLS };
669
- });
638
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
670
639
 
671
640
  // ── Auto-session management ───────────────────────────────────────────────────
672
641
  // Transparently starts a Brain session on the first tool call of a process
@@ -690,8 +659,9 @@ async function autoStartSession(instanceId: string): Promise<void> {
690
659
  _autoSessionInstanceId = instanceId;
691
660
  try {
692
661
  await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
693
- } catch { /* non-fatal — session tracking is a best-effort feature */ }
694
- _autoSessionStarting = null;
662
+ } catch { /* non-fatal — session tracking is a best-effort feature */ } finally {
663
+ _autoSessionStarting = null;
664
+ }
695
665
  })();
696
666
  await _autoSessionStarting;
697
667
 
@@ -740,7 +710,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
740
710
  // Skip session management tools to avoid recursion.
741
711
  const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
742
712
  if (!sessionTools.has(name) && !_autoSessionStarted && !_autoSessionStarting) {
743
- const instanceId = (args as Record<string, unknown>)?.instance_id as string | undefined;
713
+ const instanceId = ((args as Record<string, unknown>)?.instance_id as string | undefined)
714
+ || _defaultInstanceId
715
+ || process.env.CACHLY_BRAIN_INSTANCE_ID;
744
716
  if (instanceId) {
745
717
  await autoStartSession(instanceId).catch(() => undefined);
746
718
  }
@@ -833,54 +805,47 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
833
805
 
834
806
  function buildClaudeMdBlock(instanceId: string): string {
835
807
  return `${CLAUDE_MD_MARKER_START}
836
- ## Cachly AI Brain
808
+ ## Cachly AI Brain — Always Active
837
809
 
838
- The Cachly MCP server is available as tool \`cachly\`.
839
- Your instance ID: \`${instanceId}\`
810
+ The Brain MCP server runs automatically. Instance: \`${instanceId}\`
840
811
 
841
- > **Sessions are fully automatic** (MCP ≥ 0.5.3).
842
- > The Brain starts tracking when you call any tool for the first time and saves a summary when your editor closes. You never need to call \`session_start\` or \`session_end\` manually.
812
+ Sessions start and end automatically — never call \`session_start\` or \`session_end\`.
843
813
 
844
814
  ---
845
815
 
846
- ### Before editing any file — WIP-registry (prevents half-finished code across sessions):
816
+ ### BEFORE every task (mandatory — no exceptions):
817
+ \`\`\`
818
+ smart_recall(instance_id="${instanceId}", query="<what you are about to do>")
819
+ \`\`\`
820
+ Report any relevant lessons found to the user before proceeding.
821
+
822
+ ### BEFORE editing any file (mandatory):
847
823
  \`\`\`
848
- remember_context(
849
- instance_id = "${instanceId}",
850
- key = "wip-registry",
851
- content = "EDITING: <file:line> — goal: <what you are implementing>",
852
- category = "bug",
853
- ttl = 86400,
854
- )
824
+ remember_context(instance_id="${instanceId}", key="wip:<filename>", content="EDITING: <file:line> — <goal>", ttl=86400)
855
825
  \`\`\`
856
- After the edit is complete, update \`content\` to \`"DONE: <file> — <what was completed>"\`.
826
+ When done: update content to \`"DONE: <file> — <summary>"\`.
857
827
 
858
- ### After fixing any bug or solving a tricky problem:
828
+ ### AFTER every fix, deploy, or discovery (mandatory):
859
829
  \`\`\`
860
- learn_from_attempts(
861
- instance_id = "${instanceId}",
862
- topic = "category:keyword",
863
- outcome = "success",
864
- what_worked = "...",
865
- what_failed = "...",
866
- severity = "critical" | "major" | "minor",
867
- file_paths = ["path/to/file"],
868
- commands = ["the command that worked"],
869
- tags = ["tag1"],
870
- )
830
+ learn_from_attempts(instance_id="${instanceId}", topic="<category:keyword>", outcome="success"|"failure",
831
+ what_worked="...", what_failed="...", severity="critical"|"major"|"minor",
832
+ file_paths=["..."], commands=["..."], tags=["..."])
871
833
  \`\`\`
872
834
 
873
- ### Before starting any task — recall relevant lessons first:
835
+ ### WHEN debugging any error (mandatory before grepping/reading files):
874
836
  \`\`\`
875
- smart_recall(
876
- instance_id = "${instanceId}",
877
- query = "<describe what you are about to do>",
878
- )
837
+ causal_trace(instance_id="${instanceId}", problem="<exact error or symptom>")
879
838
  \`\`\`
839
+ If a known fix exists in the Brain, use it directly. Skip research if Brain has the answer.
840
+
841
+ ### BEFORE any risky operation (deploy, migration, dependency upgrade):
842
+ \`\`\`
843
+ brain_predict(instance_id="${instanceId}", context="<what you are about to do>")
844
+ \`\`\`
845
+ Report predicted failure risks to the user before proceeding.
880
846
 
881
847
  ### Half-finished code rule:
882
848
  Never commit code that does not compile. Run \`tsc --noEmit\` / \`go build ./...\` before every commit.
883
- If a session ends mid-task, save the WIP-registry entry so the next session picks up exactly where you left off.
884
849
  ${CLAUDE_MD_MARKER_END}`;
885
850
  }
886
851
 
@@ -987,7 +952,7 @@ if (process.argv[2] === 'init') {
987
952
  // Usage: npx @cachly-dev/mcp-server setup
988
953
 
989
954
  if (process.argv[2] === 'setup') {
990
- const { writeFile, mkdir } = await import('node:fs/promises');
955
+ const { writeFile, mkdir, readFile } = await import('node:fs/promises');
991
956
  const { existsSync } = await import('node:fs');
992
957
  const { resolve, dirname } = await import('node:path');
993
958
  const { createInterface } = await import('node:readline');
@@ -1216,7 +1181,7 @@ if (process.argv[2] === 'setup') {
1216
1181
  const editorsToSetup = detected;
1217
1182
  console.log(`Step 3: Configuring for: ${editorsToSetup.map(editorLabel).join(', ')}\n`);
1218
1183
 
1219
- // ── Step 4: Write editor configs ──────────────────────────────────────────
1184
+ // ── Step 4: Write editor configs (project-level) ─────────────────────────
1220
1185
  for (const editor of editorsToSetup) {
1221
1186
  const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
1222
1187
  const configPath = resolve(cwd, configFile);
@@ -1225,11 +1190,54 @@ if (process.argv[2] === 'setup') {
1225
1190
  console.log(`✅ Written: ${configFile}`);
1226
1191
  }
1227
1192
 
1193
+ // ── Step 4b: Write global Claude Code config (~/.claude/mcp.json) ─────────
1194
+ // Merges cachly into the existing global config so it works in every project,
1195
+ // without removing other MCP servers the user may have configured.
1196
+ const globalClaudePath = resolve(home, '.claude', 'mcp.json');
1197
+ try {
1198
+ await mkdir(dirname(globalClaudePath), { recursive: true });
1199
+ let globalConfig: { mcpServers?: Record<string, unknown> } = {};
1200
+ if (existsSync(globalClaudePath)) {
1201
+ try {
1202
+ globalConfig = JSON.parse(await readFile(globalClaudePath, 'utf-8')) as typeof globalConfig;
1203
+ } catch { /* corrupt — start fresh */ }
1204
+ }
1205
+ globalConfig.mcpServers ??= {};
1206
+ globalConfig.mcpServers['cachly'] = {
1207
+ command: 'npx',
1208
+ args: ['-y', '@cachly-dev/mcp-server@latest'],
1209
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: token, CACHLY_BRAIN_INSTANCE_ID: instance.id },
1210
+ };
1211
+ await writeFile(globalClaudePath, JSON.stringify(globalConfig, null, 2), 'utf-8');
1212
+ console.log(`✅ Written: ~/.claude/mcp.json (global — works in every project)`);
1213
+ } catch (e) {
1214
+ console.log(`⚠️ Could not write ~/.claude/mcp.json: ${(e as Error).message}`);
1215
+ }
1216
+
1228
1217
  // ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
1229
1218
  const mdResult = await writeClaudeMd(cwd, instance.id);
1230
1219
  const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
1231
1220
  console.log(`${mdLabel}: CLAUDE.md\n`);
1232
1221
 
1222
+ // ── Step 5b: Bootstrap Brain from git history ─────────────────────────────
1223
+ // Pre-populates the Brain with real lessons so the first session_start shows
1224
+ // actual project knowledge instead of an empty brain.
1225
+ if (existsSync(resolve(cwd, '.git'))) {
1226
+ process.stdout.write('⏳ Bootstrapping Brain from git history (~10s)...');
1227
+ try {
1228
+ JWT = token; // set global JWT so handleTool can authenticate
1229
+ const gitResult = await handleTool('brain_from_git', {
1230
+ instance_id: instance.id,
1231
+ dir: cwd,
1232
+ max_commits: 100,
1233
+ });
1234
+ const match = gitResult.match(/(\d+) lesson/);
1235
+ console.log(` ✓ ${match ? match[1] + ' lessons' : 'done'} extracted from git history`);
1236
+ } catch (e) {
1237
+ console.log(` (skipped: ${(e as Error).message.slice(0, 80)})`);
1238
+ }
1239
+ }
1240
+
1233
1241
  // ── Step 6: Show Brain health (Aha moment) ────────────────────────────────
1234
1242
  // Fetch brain health from the API to show the user what their agent will see.
1235
1243
  process.stdout.write('\n⏳ Fetching your Brain health preview...');
@@ -1450,7 +1458,6 @@ if (httpPort) {
1450
1458
  res.end(JSON.stringify({
1451
1459
  status: 'ok',
1452
1460
  version: CURRENT_VERSION,
1453
- toolCalls: { total: _toolCallTotal, authed: _toolCallAuthedTotal },
1454
1461
  zeroResults: {
1455
1462
  total: zeroResultsTotal,
1456
1463
  last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
@@ -1458,23 +1465,6 @@ if (httpPort) {
1458
1465
  }));
1459
1466
  return;
1460
1467
  }
1461
- if (url.pathname === '/stats') {
1462
- // Top tools sorted by call count
1463
- const top = Object.entries(_toolCallCounts)
1464
- .sort(([, a], [, b]) => b - a)
1465
- .slice(0, 20)
1466
- .map(([tool, count]) => ({ tool, count }));
1467
- res.writeHead(200, { 'Content-Type': 'application/json' });
1468
- res.end(JSON.stringify({
1469
- version: CURRENT_VERSION,
1470
- total: _toolCallTotal,
1471
- authed: _toolCallAuthedTotal,
1472
- unauthenticated: _toolCallTotal - _toolCallAuthedTotal,
1473
- topTools: top,
1474
- uptimeSec: Math.floor(process.uptime()),
1475
- }));
1476
- return;
1477
- }
1478
1468
  if (url.pathname === '/mcp' || url.pathname === '/') {
1479
1469
  transport.handleRequest(req, res);
1480
1470
  return;