@cachly-dev/mcp-server 0.10.4 → 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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
2
3
  /**
3
4
  * cachly MCP Server v0.4.0
4
5
  *
@@ -41,20 +42,23 @@ import { Redis } from 'ioredis';
41
42
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
42
43
  let JWT = process.env.CACHLY_JWT ?? '';
43
44
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
44
- const CURRENT_VERSION = '0.9.17';
45
+ const CURRENT_VERSION = '0.10.0';
45
46
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
46
47
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
47
48
  // When neither is set, we auto-fetch the first running instance once per process.
48
49
  let _defaultInstanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
49
- let _defaultInstanceFetched = false;
50
+ // Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
51
+ let _defaultInstanceLastAttempt = 0;
50
52
  async function resolveDefaultInstanceId() {
51
53
  if (_defaultInstanceId)
52
54
  return _defaultInstanceId;
53
- if (_defaultInstanceFetched)
54
- return '';
55
- _defaultInstanceFetched = true;
56
55
  if (!JWT)
57
56
  return '';
57
+ // Cooldown: don't hammer the API on every tool call after a transient failure.
58
+ const now = Date.now();
59
+ if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000)
60
+ return '';
61
+ _defaultInstanceLastAttempt = now;
58
62
  try {
59
63
  const res = await fetch(`${API_URL}/api/v1/instances`, {
60
64
  headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
@@ -63,13 +67,16 @@ async function resolveDefaultInstanceId() {
63
67
  if (!res.ok)
64
68
  return '';
65
69
  const data = await res.json();
66
- const running = (data?.data ?? []).filter(i => i.status === 'running');
67
- if (running.length > 0) {
68
- _defaultInstanceId = running[0].id;
70
+ const instances = (data?.data ?? []);
71
+ // Prefer running, fall back to provisioning so the ID is resolved even during startup.
72
+ const best = instances.find(i => i.status === 'running') ?? instances.find(i => i.status === 'provisioning');
73
+ if (best) {
74
+ _defaultInstanceId = best.id;
75
+ _defaultInstanceLastAttempt = 0;
69
76
  return _defaultInstanceId;
70
77
  }
71
78
  }
72
- catch { /* non-fatal — caller will surface missing instance_id error */ }
79
+ catch { /* transient error — will retry after cooldown */ }
73
80
  return '';
74
81
  }
75
82
  let _deviceFlow = null;
@@ -132,16 +139,15 @@ async function pollDeviceFlow(flow) {
132
139
  JWT = apiKey;
133
140
  setEmbedJwt(apiKey); // keep embeddings.ts in sync
134
141
  _deviceFlow = null;
135
- // Auto-provision instance
136
- _defaultInstanceFetched = false;
142
+ // Auto-provision: find or create the user's brain instance.
143
+ _defaultInstanceLastAttempt = 0;
137
144
  await resolveDefaultInstanceId();
138
145
  if (!_defaultInstanceId) {
139
- // Try auto-provision
140
146
  try {
141
147
  const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
142
148
  method: 'POST',
143
149
  headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
144
- signal: AbortSignal.timeout(10000),
150
+ signal: AbortSignal.timeout(12000),
145
151
  });
146
152
  if (autoRes.ok) {
147
153
  const body = await autoRes.json();
@@ -152,6 +158,38 @@ async function pollDeviceFlow(flow) {
152
158
  }
153
159
  catch { /* non-fatal */ }
154
160
  }
161
+ // Give a provisioning instance a head-start so getConnection's wait is shorter.
162
+ // The instance is almost always ready by the time the user's tool re-enters.
163
+ if (_defaultInstanceId) {
164
+ try {
165
+ const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
166
+ headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
167
+ signal: AbortSignal.timeout(4000),
168
+ });
169
+ if (checkRes.ok) {
170
+ const checkInst = await checkRes.json();
171
+ if (checkInst.status === 'provisioning') {
172
+ // Wait up to 20s here so the re-entered tool call lands on a running instance.
173
+ const pDeadline = Date.now() + 20_000;
174
+ while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
175
+ await new Promise(r => setTimeout(r, 3000));
176
+ const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
177
+ headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
178
+ signal: AbortSignal.timeout(4000),
179
+ }).catch(() => null);
180
+ if (rr?.ok) {
181
+ const ri = await rr.json();
182
+ checkInst.status = ri.status;
183
+ }
184
+ else {
185
+ break;
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ catch { /* non-fatal — getConnection will handle the wait if still provisioning */ }
192
+ }
155
193
  return 'done';
156
194
  }
157
195
  if (data.error === 'slow_down')
@@ -172,15 +210,42 @@ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp,
172
210
  /** Reuse Redis connections across tool calls (keyed by instance_id). */
173
211
  const pool = new Map();
174
212
  async function getConnection(instance_id) {
213
+ if (!instance_id) {
214
+ throw new McpError(ErrorCode.InvalidRequest, 'No instance_id provided and no running instance could be resolved automatically.\n\n' +
215
+ '• Set CACHLY_BRAIN_INSTANCE_ID in your MCP config, or\n' +
216
+ '• Pass instance_id explicitly to the tool, or\n' +
217
+ '• Run `list_instances` to see your available instances.');
218
+ }
175
219
  if (pool.has(instance_id))
176
220
  return pool.get(instance_id);
177
- const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
221
+ // Fetch instance, waiting up to 25 s if it is still provisioning.
222
+ // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
223
+ // all happen in quick succession and the instance isn't running yet.
224
+ let inst = await apiFetch(`/api/v1/instances/${instance_id}`);
225
+ if (inst.status === 'provisioning') {
226
+ const deadline = Date.now() + 25_000;
227
+ while (inst.status === 'provisioning' && Date.now() < deadline) {
228
+ await new Promise(r => setTimeout(r, 3000));
229
+ inst = await apiFetch(`/api/v1/instances/${instance_id}`);
230
+ }
231
+ }
178
232
  if (inst.status !== 'running') {
179
- throw new McpError(ErrorCode.InvalidRequest, `Instance "${inst.name}" is not running (status: ${inst.status}). ` +
180
- `It may still be provisioning or awaiting payment.`);
233
+ // Telemetry: unreachable instance so the team can proactively investigate
234
+ void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
237
+ body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
238
+ signal: AbortSignal.timeout(3000),
239
+ }).catch(() => { });
240
+ const hint = inst.status === 'provisioning'
241
+ ? `⏳ Brain instance "${inst.name}" is still starting up.\n\nThis usually finishes within 30 seconds. Please try again in a moment.`
242
+ : `Brain instance "${inst.name}" is not reachable (status: ${inst.status}).\n\n` +
243
+ `• View your instance at: https://cachly.dev/instances\n` +
244
+ `• Run \`get_api_status\` for a full diagnostic.`;
245
+ throw new McpError(ErrorCode.InvalidRequest, hint);
181
246
  }
182
247
  if (!inst.host || !inst.port) {
183
- throw new McpError(ErrorCode.InternalError, `Instance "${inst.name}" has no host/port yet.`);
248
+ throw new McpError(ErrorCode.InternalError, `Instance "${inst.name}" is running but has no host/port — contact support@cachly.dev.`);
184
249
  }
185
250
  // Fetch connection details (includes password) from the dedicated endpoint.
186
251
  let password;
@@ -191,57 +256,44 @@ async function getConnection(instance_id) {
191
256
  tlsEnabled = conn.tls_enabled !== false;
192
257
  }
193
258
  catch {
194
- // Fallback: no password, use TLS default from instance
259
+ // Fallback: use TLS flag from instance metadata
195
260
  }
196
261
  const client = new Redis({
197
262
  host: inst.host,
198
263
  port: inst.port,
199
264
  password: password || undefined,
200
265
  ...(tlsEnabled ? { tls: {} } : {}),
201
- lazyConnect: true, // don't auto-connect — we connect explicitly below
266
+ lazyConnect: true,
202
267
  enableReadyCheck: true,
203
- connectTimeout: 5000,
268
+ connectTimeout: 8000,
204
269
  retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
205
270
  });
206
- client.on('error', () => {
207
- pool.delete(instance_id); // remove stale connection on error
208
- });
209
- // Connect explicitly so we can catch connection errors as a proper rejected
210
- // Promise instead of an unhandled 'error' event that would kill the process.
271
+ const evict = () => pool.delete(instance_id);
272
+ client.on('error', evict);
273
+ client.on('end', evict);
211
274
  try {
212
275
  await client.connect();
213
276
  }
214
277
  catch (err) {
215
278
  client.disconnect();
216
279
  const msg = err instanceof Error ? err.message : String(err);
217
- throw new McpError(ErrorCode.InternalError, `Could not connect to instance "${inst.name}" (${inst.host}:${inst.port}): ${msg}`);
280
+ void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
283
+ body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
284
+ signal: AbortSignal.timeout(3000),
285
+ }).catch(() => { });
286
+ throw new McpError(ErrorCode.InternalError, `Could not reach Brain instance "${inst.name}" (${inst.host}:${inst.port}).\n\n` +
287
+ `• Check your network connection\n` +
288
+ `• Run \`get_api_status\` to verify instance status\n` +
289
+ `• Technical detail: ${msg}`);
218
290
  }
219
291
  pool.set(instance_id, client);
220
292
  return client;
221
293
  }
222
294
  // ── API helper ────────────────────────────────────────────────────────────────
223
- function jwtExpiryMs(token) {
224
- try {
225
- const parts = token.split('.');
226
- if (parts.length !== 3)
227
- return null;
228
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
229
- return payload.exp ? payload.exp * 1000 : null;
230
- }
231
- catch {
232
- return null;
233
- }
234
- }
235
295
  async function apiFetch(path, options = {}) {
236
- if (!JWT) {
237
- throw new McpError(ErrorCode.InvalidRequest, '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>');
238
- }
239
- // Detect token expiry before making the network call — gives a clearer error
240
- const expMs = jwtExpiryMs(JWT);
241
- if (expMs !== null && expMs < Date.now()) {
242
- const expiredAt = new Date(expMs).toISOString();
243
- throw new McpError(ErrorCode.InvalidRequest, `CACHLY_JWT expired at ${expiredAt}.\n\nGet a fresh token at https://cachly.dev/setup-ai and update CACHLY_JWT in your MCP config.`);
244
- }
296
+ checkJwt(JWT);
245
297
  const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
246
298
  const res = await fetch(`${API_URL}${path}`, {
247
299
  ...options,
@@ -255,13 +307,7 @@ async function apiFetch(path, options = {}) {
255
307
  if (!res.ok) {
256
308
  const body = await res.json().catch(() => ({ error: res.statusText }));
257
309
  const detail = body.error ?? res.statusText;
258
- if (res.status === 401) {
259
- throw new McpError(ErrorCode.InvalidRequest, `Authentication failed (401): ${detail}\n\nYour CACHLY_JWT may be expired or invalid. Get a fresh token at https://cachly.dev/setup-ai`);
260
- }
261
- if (res.status === 403) {
262
- throw new McpError(ErrorCode.InvalidRequest, `Access denied (403): ${detail}\n\nCheck that your CACHLY_JWT belongs to an account with access to this resource.`);
263
- }
264
- throw new McpError(ErrorCode.InternalError, `cachly API error ${res.status}: ${detail}`);
310
+ handleApiError(res.status, detail);
265
311
  }
266
312
  if (res.status === 204)
267
313
  return undefined;
@@ -279,48 +325,6 @@ import { handleFedbrainTool } from './handlers/fedbrain.js';
279
325
  // ── Tools (imported from tools.ts) ─────────────────────────────────────────
280
326
  import { TOOLS } from './tools.js';
281
327
  // ── Handlers ──────────────────────────────────────────────────────────────────
282
- // ── Tool-call analytics (opt-out: CACHLY_NO_TELEMETRY=1) ────────────────────
283
- // In-memory counters — exposed via /stats on HTTP mode
284
- const _toolCallCounts = {};
285
- let _toolCallTotal = 0;
286
- let _toolCallAuthedTotal = 0;
287
- async function trackToolCall(toolName, authed, durationMs) {
288
- if (process.env.CACHLY_NO_TELEMETRY === '1')
289
- return;
290
- // Increment in-memory counters
291
- _toolCallTotal++;
292
- if (authed)
293
- _toolCallAuthedTotal++;
294
- _toolCallCounts[toolName] = (_toolCallCounts[toolName] ?? 0) + 1;
295
- // Structured JSON to stdout — searchable via: docker logs cachly-mcp-http-1 | jq
296
- process.stdout.write(JSON.stringify({
297
- ts: new Date().toISOString(),
298
- event: 'tool_call',
299
- tool: toolName,
300
- authed,
301
- ms: durationMs,
302
- v: CURRENT_VERSION,
303
- }) + '\n');
304
- // Plausible custom event (fire-and-forget, never blocks user)
305
- try {
306
- await fetch('https://analytics.cachly.dev/api/event', {
307
- method: 'POST',
308
- headers: {
309
- 'Content-Type': 'application/json',
310
- 'User-Agent': `cachly-mcp/${CURRENT_VERSION}`,
311
- 'X-Forwarded-For': '127.0.0.1',
312
- },
313
- body: JSON.stringify({
314
- domain: 'mcp.cachly.dev',
315
- name: 'tool_call',
316
- url: `https://mcp.cachly.dev/mcp/${toolName}`,
317
- props: { tool: toolName, authed: String(authed) },
318
- }),
319
- signal: AbortSignal.timeout(3000),
320
- });
321
- }
322
- catch { /* fire-and-forget */ }
323
- }
324
328
  // Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
325
329
  let _telemetryPingSent = false;
326
330
  async function sendAnonymousTelemetry(toolName) {
@@ -346,61 +350,6 @@ async function sendAnonymousTelemetry(toolName) {
346
350
  catch { /* fire-and-forget, never block the user */ }
347
351
  }
348
352
  async function handleTool(name, args) {
349
- // ── sign_in: explicit device-flow trigger — works even without JWT ────────
350
- if (name === 'sign_in') {
351
- if (JWT) {
352
- return '✅ **Already signed in.** Your cachly AI Brain is ready — all 63 tools available.';
353
- }
354
- // Poll if flow already in progress
355
- if (_deviceFlow) {
356
- const result = await pollDeviceFlow(_deviceFlow);
357
- if (result === 'done') {
358
- return [
359
- '✅ **Sign-in complete!** Your cachly AI Brain is ready.',
360
- '',
361
- 'All 63 brain tools are now available. Start with `session_start` to load context from your last session.',
362
- ].join('\n');
363
- }
364
- if (result === 'expired') {
365
- _deviceFlow = null;
366
- // fall through to start a new flow
367
- }
368
- else {
369
- return [
370
- '⏳ **Waiting for you to approve in the browser...**',
371
- '',
372
- `Sign in at: **${_deviceFlow.verifyUrl}**`,
373
- `Code: **${_deviceFlow.userCode}**`,
374
- '',
375
- 'After approving, call `sign_in` again — it will complete automatically.',
376
- ].join('\n');
377
- }
378
- }
379
- const flow = await startDeviceFlow();
380
- if (flow) {
381
- _deviceFlow = flow;
382
- return [
383
- '🧠 **cachly AI Brain — One-click sign in**',
384
- '',
385
- '1. Open this URL (it may open automatically):',
386
- ` **${flow.verifyUrl}**`,
387
- '',
388
- `2. If prompted for a code, enter: **${flow.userCode}**`,
389
- '',
390
- '3. After approving in your browser, call `sign_in` again — it will complete automatically.',
391
- '',
392
- '✨ Free tier: 1 Brain instance · persistent memory · 63 MCP tools · no credit card required.',
393
- ].join('\n');
394
- }
395
- return [
396
- '🧠 **cachly AI Brain — Setup required**',
397
- '',
398
- 'Run once in your terminal:',
399
- ' `npx @cachly-dev/mcp-server@latest setup`',
400
- '',
401
- 'Or sign up at: https://cachly.dev/setup-ai',
402
- ].join('\n');
403
- }
404
353
  // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
405
354
  if (!JWT) {
406
355
  void sendAnonymousTelemetry(name);
@@ -459,9 +408,6 @@ async function handleTool(name, args) {
459
408
  '✨ Free tier includes: 1 Brain instance, persistent memory, semantic search.',
460
409
  ].join('\n');
461
410
  }
462
- // Track authenticated tool call (fire-and-forget)
463
- const _trackStart = Date.now();
464
- void (async () => { await trackToolCall(name, true, Date.now() - _trackStart); })();
465
411
  // Auto-resolve instance_id from env / API when not provided in args
466
412
  if (!args.instance_id) {
467
413
  const defaultId = await resolveDefaultInstanceId();
@@ -552,6 +498,49 @@ async function handleTool(name, args) {
552
498
  catch {
553
499
  authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
554
500
  }
501
+ // List instances + connectivity
502
+ let instanceInfo = '';
503
+ try {
504
+ const listRes = await apiFetch('/api/v1/instances');
505
+ const instances = listRes.data ?? [];
506
+ if (instances.length === 0) {
507
+ instanceInfo = '\n\n🧠 **Brain Instances:** none — create one at https://cachly.dev/instances';
508
+ }
509
+ else {
510
+ const lines = ['\n\n🧠 **Brain Instances:**'];
511
+ for (const inst of instances) {
512
+ const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
513
+ const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
514
+ lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
515
+ if (inst.status === 'running' && inst.host) {
516
+ // Try a quick ping on the cached connection
517
+ const pooled = pool.get(inst.id);
518
+ if (pooled) {
519
+ try {
520
+ await pooled.ping();
521
+ lines.push(` 💓 Redis: connected`);
522
+ }
523
+ catch {
524
+ lines.push(` ⚠️ Redis: pooled but ping failed — will reconnect on next use`);
525
+ }
526
+ }
527
+ else {
528
+ lines.push(` 💤 Redis: not connected yet (connects on first tool use)`);
529
+ }
530
+ }
531
+ else if (inst.status === 'provisioning') {
532
+ lines.push(` ⏳ Still provisioning — check back in ~30 s`);
533
+ }
534
+ else if (inst.status !== 'running') {
535
+ lines.push(` ❌ Not reachable (status: ${inst.status})`);
536
+ }
537
+ }
538
+ instanceInfo = lines.join('\n');
539
+ }
540
+ }
541
+ catch (e) {
542
+ instanceInfo = `\n\n🧠 **Brain Instances:** could not fetch — ${e.message}`;
543
+ }
555
544
  return [
556
545
  `📡 **cachly API Status**`,
557
546
  ``,
@@ -560,6 +549,7 @@ async function handleTool(name, args) {
560
549
  ``,
561
550
  `🔑 **Auth:**`,
562
551
  authInfo,
552
+ instanceInfo,
563
553
  ].join('\n');
564
554
  }
565
555
  default:
@@ -567,15 +557,7 @@ async function handleTool(name, args) {
567
557
  }
568
558
  }
569
559
  const server = new Server({ name: 'cachly-mcp', version: CURRENT_VERSION }, { capabilities: { tools: {} } });
570
- server.setRequestHandler(ListToolsRequestSchema, async () => {
571
- if (!JWT) {
572
- // Surface sign_in as the only prominent tool when not authenticated,
573
- // so AI assistants proactively call it before attempting any brain tool.
574
- const signInTool = TOOLS.find(t => t.name === 'sign_in');
575
- return { tools: signInTool ? [signInTool] : TOOLS };
576
- }
577
- return { tools: TOOLS };
578
- });
560
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
579
561
  // ── Auto-session management ───────────────────────────────────────────────────
580
562
  // Transparently starts a Brain session on the first tool call of a process
581
563
  // and saves a session_end summary on SIGTERM/exit so users never have to call
@@ -602,7 +584,9 @@ async function autoStartSession(instanceId) {
602
584
  await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
603
585
  }
604
586
  catch { /* non-fatal — session tracking is a best-effort feature */ }
605
- _autoSessionStarting = null;
587
+ finally {
588
+ _autoSessionStarting = null;
589
+ }
606
590
  })();
607
591
  await _autoSessionStarting;
608
592
  // Auto-index the project if it hasn't been indexed in the last 24h.
@@ -650,7 +634,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
650
634
  // Skip session management tools to avoid recursion.
651
635
  const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
652
636
  if (!sessionTools.has(name) && !_autoSessionStarted && !_autoSessionStarting) {
653
- const instanceId = args?.instance_id;
637
+ const instanceId = args?.instance_id
638
+ || _defaultInstanceId
639
+ || process.env.CACHLY_BRAIN_INSTANCE_ID;
654
640
  if (instanceId) {
655
641
  await autoStartSession(instanceId).catch(() => undefined);
656
642
  }
@@ -740,54 +726,47 @@ function buildMcpConfig(apiKey, instanceId, editor) {
740
726
  }
741
727
  function buildClaudeMdBlock(instanceId) {
742
728
  return `${CLAUDE_MD_MARKER_START}
743
- ## Cachly AI Brain
729
+ ## Cachly AI Brain — Always Active
744
730
 
745
- The Cachly MCP server is available as tool \`cachly\`.
746
- Your instance ID: \`${instanceId}\`
731
+ The Brain MCP server runs automatically. Instance: \`${instanceId}\`
747
732
 
748
- > **Sessions are fully automatic** (MCP ≥ 0.5.3).
749
- > 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.
733
+ Sessions start and end automatically — never call \`session_start\` or \`session_end\`.
750
734
 
751
735
  ---
752
736
 
753
- ### Before editing any file — WIP-registry (prevents half-finished code across sessions):
737
+ ### BEFORE every task (mandatory — no exceptions):
738
+ \`\`\`
739
+ smart_recall(instance_id="${instanceId}", query="<what you are about to do>")
740
+ \`\`\`
741
+ Report any relevant lessons found to the user before proceeding.
742
+
743
+ ### BEFORE editing any file (mandatory):
744
+ \`\`\`
745
+ remember_context(instance_id="${instanceId}", key="wip:<filename>", content="EDITING: <file:line> — <goal>", ttl=86400)
746
+ \`\`\`
747
+ When done: update content to \`"DONE: <file> — <summary>"\`.
748
+
749
+ ### AFTER every fix, deploy, or discovery (mandatory):
754
750
  \`\`\`
755
- remember_context(
756
- instance_id = "${instanceId}",
757
- key = "wip-registry",
758
- content = "EDITING: <file:line> — goal: <what you are implementing>",
759
- category = "bug",
760
- ttl = 86400,
761
- )
751
+ learn_from_attempts(instance_id="${instanceId}", topic="<category:keyword>", outcome="success"|"failure",
752
+ what_worked="...", what_failed="...", severity="critical"|"major"|"minor",
753
+ file_paths=["..."], commands=["..."], tags=["..."])
762
754
  \`\`\`
763
- After the edit is complete, update \`content\` to \`"DONE: <file> — <what was completed>"\`.
764
755
 
765
- ### After fixing any bug or solving a tricky problem:
756
+ ### WHEN debugging any error (mandatory before grepping/reading files):
766
757
  \`\`\`
767
- learn_from_attempts(
768
- instance_id = "${instanceId}",
769
- topic = "category:keyword",
770
- outcome = "success",
771
- what_worked = "...",
772
- what_failed = "...",
773
- severity = "critical" | "major" | "minor",
774
- file_paths = ["path/to/file"],
775
- commands = ["the command that worked"],
776
- tags = ["tag1"],
777
- )
758
+ causal_trace(instance_id="${instanceId}", problem="<exact error or symptom>")
778
759
  \`\`\`
760
+ If a known fix exists in the Brain, use it directly. Skip research if Brain has the answer.
779
761
 
780
- ### Before starting any task — recall relevant lessons first:
762
+ ### BEFORE any risky operation (deploy, migration, dependency upgrade):
781
763
  \`\`\`
782
- smart_recall(
783
- instance_id = "${instanceId}",
784
- query = "<describe what you are about to do>",
785
- )
764
+ brain_predict(instance_id="${instanceId}", context="<what you are about to do>")
786
765
  \`\`\`
766
+ Report predicted failure risks to the user before proceeding.
787
767
 
788
768
  ### Half-finished code rule:
789
769
  Never commit code that does not compile. Run \`tsc --noEmit\` / \`go build ./...\` before every commit.
790
- If a session ends mid-task, save the WIP-registry entry so the next session picks up exactly where you left off.
791
770
  ${CLAUDE_MD_MARKER_END}`;
792
771
  }
793
772
  async function writeClaudeMd(projectDir, instanceId) {
@@ -884,7 +863,7 @@ if (process.argv[2] === 'init') {
884
863
  // ── CLI: cachly setup (interactive — no flags required) ───────────────────────
885
864
  // Usage: npx @cachly-dev/mcp-server setup
886
865
  if (process.argv[2] === 'setup') {
887
- const { writeFile, mkdir } = await import('node:fs/promises');
866
+ const { writeFile, mkdir, readFile } = await import('node:fs/promises');
888
867
  const { existsSync } = await import('node:fs');
889
868
  const { resolve, dirname } = await import('node:path');
890
869
  const { createInterface } = await import('node:readline');
@@ -1133,7 +1112,7 @@ if (process.argv[2] === 'setup') {
1133
1112
  // Auto-configure all detected editors — no prompt needed.
1134
1113
  const editorsToSetup = detected;
1135
1114
  console.log(`Step 3: Configuring for: ${editorsToSetup.map(editorLabel).join(', ')}\n`);
1136
- // ── Step 4: Write editor configs ──────────────────────────────────────────
1115
+ // ── Step 4: Write editor configs (project-level) ─────────────────────────
1137
1116
  for (const editor of editorsToSetup) {
1138
1117
  const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
1139
1118
  const configPath = resolve(cwd, configFile);
@@ -1141,10 +1120,54 @@ if (process.argv[2] === 'setup') {
1141
1120
  await writeFile(configPath, buildMcpConfig(token, instance.id, editor), 'utf-8');
1142
1121
  console.log(`✅ Written: ${configFile}`);
1143
1122
  }
1123
+ // ── Step 4b: Write global Claude Code config (~/.claude/mcp.json) ─────────
1124
+ // Merges cachly into the existing global config so it works in every project,
1125
+ // without removing other MCP servers the user may have configured.
1126
+ const globalClaudePath = resolve(home, '.claude', 'mcp.json');
1127
+ try {
1128
+ await mkdir(dirname(globalClaudePath), { recursive: true });
1129
+ let globalConfig = {};
1130
+ if (existsSync(globalClaudePath)) {
1131
+ try {
1132
+ globalConfig = JSON.parse(await readFile(globalClaudePath, 'utf-8'));
1133
+ }
1134
+ catch { /* corrupt — start fresh */ }
1135
+ }
1136
+ globalConfig.mcpServers ??= {};
1137
+ globalConfig.mcpServers['cachly'] = {
1138
+ command: 'npx',
1139
+ args: ['-y', '@cachly-dev/mcp-server@latest'],
1140
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: token, CACHLY_BRAIN_INSTANCE_ID: instance.id },
1141
+ };
1142
+ await writeFile(globalClaudePath, JSON.stringify(globalConfig, null, 2), 'utf-8');
1143
+ console.log(`✅ Written: ~/.claude/mcp.json (global — works in every project)`);
1144
+ }
1145
+ catch (e) {
1146
+ console.log(`⚠️ Could not write ~/.claude/mcp.json: ${e.message}`);
1147
+ }
1144
1148
  // ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
1145
1149
  const mdResult = await writeClaudeMd(cwd, instance.id);
1146
1150
  const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
1147
1151
  console.log(`${mdLabel}: CLAUDE.md\n`);
1152
+ // ── Step 5b: Bootstrap Brain from git history ─────────────────────────────
1153
+ // Pre-populates the Brain with real lessons so the first session_start shows
1154
+ // actual project knowledge instead of an empty brain.
1155
+ if (existsSync(resolve(cwd, '.git'))) {
1156
+ process.stdout.write('⏳ Bootstrapping Brain from git history (~10s)...');
1157
+ try {
1158
+ JWT = token; // set global JWT so handleTool can authenticate
1159
+ const gitResult = await handleTool('brain_from_git', {
1160
+ instance_id: instance.id,
1161
+ dir: cwd,
1162
+ max_commits: 100,
1163
+ });
1164
+ const match = gitResult.match(/(\d+) lesson/);
1165
+ console.log(` ✓ ${match ? match[1] + ' lessons' : 'done'} extracted from git history`);
1166
+ }
1167
+ catch (e) {
1168
+ console.log(` (skipped: ${e.message.slice(0, 80)})`);
1169
+ }
1170
+ }
1148
1171
  // ── Step 6: Show Brain health (Aha moment) ────────────────────────────────
1149
1172
  // Fetch brain health from the API to show the user what their agent will see.
1150
1173
  process.stdout.write('\n⏳ Fetching your Brain health preview...');
@@ -1350,7 +1373,6 @@ if (httpPort) {
1350
1373
  res.end(JSON.stringify({
1351
1374
  status: 'ok',
1352
1375
  version: CURRENT_VERSION,
1353
- toolCalls: { total: _toolCallTotal, authed: _toolCallAuthedTotal },
1354
1376
  zeroResults: {
1355
1377
  total: zeroResultsTotal,
1356
1378
  last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
@@ -1358,23 +1380,6 @@ if (httpPort) {
1358
1380
  }));
1359
1381
  return;
1360
1382
  }
1361
- if (url.pathname === '/stats') {
1362
- // Top tools sorted by call count
1363
- const top = Object.entries(_toolCallCounts)
1364
- .sort(([, a], [, b]) => b - a)
1365
- .slice(0, 20)
1366
- .map(([tool, count]) => ({ tool, count }));
1367
- res.writeHead(200, { 'Content-Type': 'application/json' });
1368
- res.end(JSON.stringify({
1369
- version: CURRENT_VERSION,
1370
- total: _toolCallTotal,
1371
- authed: _toolCallAuthedTotal,
1372
- unauthenticated: _toolCallTotal - _toolCallAuthedTotal,
1373
- topTools: top,
1374
- uptimeSec: Math.floor(process.uptime()),
1375
- }));
1376
- return;
1377
- }
1378
1383
  if (url.pathname === '/mcp' || url.pathname === '/') {
1379
1384
  transport.handleRequest(req, res);
1380
1385
  return;