@cachly-dev/mcp-server 0.10.0 → 0.10.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.
@@ -6,7 +6,6 @@ import { calculateConfidence, confidenceBadge, CONFIDENCE_WARN_VALUE, CONFIDENCE
6
6
  import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
7
7
  import type { CKGEdge, CKGNode } from '../ckg.js';
8
8
  import { keywordSearch, tokenize } from '../search.js';
9
- import { safeJsonParse } from '../utils.js';
10
9
  import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
11
10
 
12
11
  // Git concurrency semaphore (for brain_from_git parallel workers)
@@ -398,8 +397,7 @@ export async function handleFedbrainTool(
398
397
  const raw = await redis.get(`cachly:lesson:best:${lesson_key}`);
399
398
  if (!raw) return `❌ Lesson \`${lesson_key}\` not found. Store it first with \`learn_from_attempts\`.`;
400
399
 
401
- const lesson = safeJsonParse(raw, null as null | { topic: string; outcome: string; what_worked: string; what_failed?: string; tags?: string[]; commands?: string[]; severity?: string; ts?: string });
402
- if (!lesson) return `❌ Lesson \`${lesson_key}\` data is corrupted. Re-store with \`learn_from_attempts\`.`;
400
+ const lesson = JSON.parse(raw) as { topic: string; outcome: string; what_worked: string; what_failed?: string; tags?: string[]; commands?: string[]; severity?: string; ts?: string };
403
401
 
404
402
  const domainTokens = [lesson.topic.split(':')[0], ...(lesson.tags ?? [])].filter(Boolean);
405
403
  const domainFingerprint = [...new Set(domainTokens)].sort().join(',');
@@ -749,8 +747,7 @@ export async function handleFedbrainTool(
749
747
  }
750
748
 
751
749
  type Crystal = { label: string; ts: string; session_count: number; lesson_count: number; top_patterns: Array<{ category: string; insight: string; count: number }>; categories: string[]; created_from: string };
752
- const crystal = safeJsonParse<Crystal | null>(raw, null);
753
- if (!crystal) return `❌ Memory Crystal data is corrupted. Re-create with \`memory_crystalize()\`.`;
750
+ const crystal: Crystal = JSON.parse(raw);
754
751
  const age = Math.floor((Date.now() - new Date(crystal.ts).getTime()) / 86400000);
755
752
  const freshEmoji = age <= 7 ? '🟢' : age <= 30 ? '🟡' : '🔴';
756
753
 
@@ -787,55 +784,49 @@ export async function handleFedbrainTool(
787
784
  const crystalRaw = await redis.get('cachly:crystal:latest');
788
785
  if (crystalRaw) {
789
786
  type Crystal = { label: string; ts: string; session_count: number; lesson_count: number; top_patterns: Array<{ category: string; insight: string; count: number }> };
790
- const crystal = safeJsonParse<Crystal | null>(crystalRaw, null);
791
- if (crystal) {
792
- lines.push(`### 💎 Memory Crystal: ${crystal.label}`);
793
- lines.push(`Compressed from ${crystal.session_count} sessions, ${crystal.lesson_count} lessons.`);
794
- const topN = focus
795
- ? crystal.top_patterns.filter(p => p.category.toLowerCase().includes(focus.toLowerCase()) || p.insight.toLowerCase().includes(focus.toLowerCase())).slice(0, 4)
796
- : crystal.top_patterns.slice(0, 4);
797
- for (const p of topN) lines.push(` • **${p.category}**: ${p.insight.slice(0, 100)}`);
798
- lines.push('');
799
- }
787
+ const crystal: Crystal = JSON.parse(crystalRaw);
788
+ lines.push(`### 💎 Memory Crystal: ${crystal.label}`);
789
+ lines.push(`Compressed from ${crystal.session_count} sessions, ${crystal.lesson_count} lessons.`);
790
+ const topN = focus
791
+ ? crystal.top_patterns.filter(p => p.category.toLowerCase().includes(focus.toLowerCase()) || p.insight.toLowerCase().includes(focus.toLowerCase())).slice(0, 4)
792
+ : crystal.top_patterns.slice(0, 4);
793
+ for (const p of topN) lines.push(` • **${p.category}**: ${p.insight.slice(0, 100)}`);
794
+ lines.push('');
800
795
  }
801
796
 
802
797
  // 2. Last session summary
803
798
  const lastSession = await redis.get('cachly:session:last');
804
799
  if (lastSession) {
805
800
  type Session = { summary?: string; ts?: string; focus?: string };
806
- const sess = safeJsonParse<Session | null>(lastSession, null);
807
- if (sess) {
808
- lines.push(`### 🕐 Last Session`);
809
- if (sess.focus) lines.push(`Focus: _${sess.focus}_`);
810
- if (sess.summary) lines.push(`Summary: ${sess.summary.slice(0, 300)}`);
811
- lines.push('');
812
- }
801
+ const sess: Session = JSON.parse(lastSession);
802
+ lines.push(`### 🕐 Last Session`);
803
+ if (sess.focus) lines.push(`Focus: _${sess.focus}_`);
804
+ if (sess.summary) lines.push(`Summary: ${sess.summary.slice(0, 300)}`);
805
+ lines.push('');
813
806
  }
814
807
 
815
808
  // 3. Session handoff
816
809
  const handoff = await redis.get('cachly:session:handoff');
817
810
  if (handoff) {
818
811
  type Handoff = { remaining_tasks?: string[]; instructions?: string; context_summary?: string; blocked_on?: string };
819
- const h = safeJsonParse<Handoff | null>(handoff, null);
820
- if (h) {
821
- lines.push(`### 📋 Handoff (from last window)`);
822
- if (h.context_summary) lines.push(`Context: ${h.context_summary.slice(0, 200)}`);
823
- if (h.remaining_tasks?.length) {
824
- lines.push(`Remaining tasks:`);
825
- for (const t of h.remaining_tasks.slice(0, 5)) lines.push(` • ${t}`);
826
- }
827
- if (h.instructions) lines.push(`⚠️ Instructions: ${h.instructions.slice(0, 200)}`);
828
- if (h.blocked_on) lines.push(`🚧 Blocked on: ${h.blocked_on}`);
829
- lines.push('');
812
+ const h: Handoff = JSON.parse(handoff);
813
+ lines.push(`### 📋 Handoff (from last window)`);
814
+ if (h.context_summary) lines.push(`Context: ${h.context_summary.slice(0, 200)}`);
815
+ if (h.remaining_tasks?.length) {
816
+ lines.push(`Remaining tasks:`);
817
+ for (const t of h.remaining_tasks.slice(0, 5)) lines.push(` • ${t}`);
830
818
  }
819
+ if (h.instructions) lines.push(`⚠️ Instructions: ${h.instructions.slice(0, 200)}`);
820
+ if (h.blocked_on) lines.push(`🚧 Blocked on: ${h.blocked_on}`);
821
+ lines.push('');
831
822
  }
832
823
 
833
824
  // 4. WIP registry
834
825
  const wipRaw = await redis.get('cachly:ctx:wip-registry');
835
826
  if (wipRaw) {
836
827
  type Ctx = { content?: string };
837
- const wip = safeJsonParse<Ctx | null>(wipRaw, null);
838
- if (wip?.content) {
828
+ const wip: Ctx = JSON.parse(wipRaw);
829
+ if (wip.content) {
839
830
  lines.push(`### 🔧 WIP Registry`);
840
831
  lines.push(wip.content.slice(0, 400));
841
832
  lines.push('');
@@ -1035,14 +1026,12 @@ export async function handleFedbrainTool(
1035
1026
  for (const nk of nodeKeys.slice(0, 5)) {
1036
1027
  const nodeRaw = await redis.get(nk);
1037
1028
  if (!nodeRaw) continue;
1038
- const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
1039
- if (!node) continue;
1029
+ const node: CKGNode = JSON.parse(nodeRaw);
1040
1030
  const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
1041
1031
  for (const ek of edgeKeys.slice(0, 20)) {
1042
1032
  const edgeRaw = await redis.get(ek);
1043
1033
  if (!edgeRaw) continue;
1044
- const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
1045
- if (!edge) continue;
1034
+ const edge: CKGEdge = JSON.parse(edgeRaw);
1046
1035
  if (edge.edgeType !== 'causes' && edge.edgeType !== 'degrades_under') continue;
1047
1036
 
1048
1037
  // Look up fix for this failure from CKG 'fixes' edges
@@ -1051,13 +1040,12 @@ export async function handleFedbrainTool(
1051
1040
  for (const fek of fixEdgeKeys.slice(0, 10)) {
1052
1041
  const feRaw = await redis.get(fek);
1053
1042
  if (!feRaw) continue;
1054
- const fe = safeJsonParse<CKGEdge | null>(feRaw, null);
1055
- if (!fe) continue;
1043
+ const fe: CKGEdge = JSON.parse(feRaw);
1056
1044
  if (fe.edgeType === 'fixes') {
1057
1045
  const lessonRaw = await redis.get(`cachly:lesson:best:${fe.from}`);
1058
1046
  if (lessonRaw) {
1059
- const lesson = safeJsonParse(lessonRaw, null as null | { what_worked?: string });
1060
- fix = lesson?.what_worked?.slice(0, 120);
1047
+ const lesson = JSON.parse(lessonRaw) as { what_worked?: string };
1048
+ fix = lesson.what_worked?.slice(0, 120);
1061
1049
  break;
1062
1050
  }
1063
1051
  }
@@ -5,7 +5,6 @@ import type { Redis } from 'ioredis';
5
5
  import { calculateConfidence, confidenceBadge, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_VALUE,
6
6
  CONFIDENCE_WARN_DAYS, CONFIDENCE_STALE_DAYS, simpleHash } from '../confidence.js';
7
7
  import type { Instance } from './brain.js';
8
- import { safeJsonParse } from '../utils.js';
9
8
 
10
9
  type GetConnection = (instanceId: string) => Promise<Redis>;
11
10
  type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
@@ -54,11 +53,10 @@ export async function handleTeamTool(
54
53
 
55
54
  type Lesson = { topic: string; what_worked: string; outcome: string; file_paths?: string[] };
56
55
  const relevant: string[] = [];
57
- const sfRaws = lessonKeys.length > 0 ? await redis.mget(...lessonKeys) : [];
58
- for (const raw of sfRaws) {
56
+ for (const k of lessonKeys) {
57
+ const raw = await redis.get(k);
59
58
  if (!raw) continue;
60
- const lesson = safeJsonParse<Lesson | null>(raw, null);
61
- if (!lesson) continue;
59
+ const lesson = JSON.parse(raw) as Lesson;
62
60
  // Match by file_paths stored in lesson OR by topic keywords matching file name
63
61
  const topicWords = lesson.topic.toLowerCase().split(/[:\-_]/);
64
62
  const fileMatches = changed_files.some(f => {
@@ -148,12 +146,10 @@ export async function handleTeamTool(
148
146
  author?: string; tags?: string[];
149
147
  };
150
148
  let lessons: TeamLesson[] = [];
151
- if (lessonKeys.length > 0) {
152
- const raws = await redis.mget(...lessonKeys);
153
- for (const raw of raws) {
154
- const l = safeJsonParse<TeamLesson | null>(raw ?? null, null);
155
- if (l) lessons.push(l);
156
- }
149
+ for (const k of lessonKeys) {
150
+ const raw = await redis.get(k);
151
+ if (!raw) continue;
152
+ try { lessons.push(JSON.parse(raw) as TeamLesson); } catch { /* skip */ }
157
153
  }
158
154
 
159
155
  // Filter
@@ -282,12 +278,10 @@ export async function handleTeamTool(
282
278
 
283
279
  type RawLesson = { topic: string; outcome: string; what_worked: string; severity?: string; ts: string; auto_learned?: boolean };
284
280
  const allLessons: RawLesson[] = [];
285
- if (allLessonKeys.length > 0) {
286
- const raws = await redis.mget(...allLessonKeys);
287
- for (const raw of raws) {
288
- const l = safeJsonParse<RawLesson | null>(raw ?? null, null);
289
- if (l) allLessons.push(l);
290
- }
281
+ for (const k of allLessonKeys) {
282
+ const raw = await redis.get(k);
283
+ if (!raw) continue;
284
+ try { allLessons.push(JSON.parse(raw) as RawLesson); } catch { /* skip */ }
291
285
  }
292
286
 
293
287
  // Group lessons by top-level category
@@ -373,17 +367,18 @@ export async function handleTeamTool(
373
367
  verified_at?: string; severity?: string; audit_trail?: unknown[];
374
368
  };
375
369
  const lessons: DrLesson[] = [];
376
- if (lessonKeys.length > 0) {
377
- const raws = await redis.mget(...lessonKeys);
378
- for (const raw of raws) {
379
- const l = safeJsonParse<DrLesson | null>(raw ?? null, null);
380
- if (l) lessons.push(l);
381
- }
370
+ for (const k of lessonKeys) {
371
+ const raw = await redis.get(k);
372
+ if (!raw) continue;
373
+ try { lessons.push(JSON.parse(raw) as DrLesson); } catch { /* skip */ }
382
374
  }
383
375
 
384
376
  // Last session
385
377
  const lastSessionRaw = await redis.get('cachly:session:last');
386
- const lastSession = safeJsonParse<{ ts: string; summary: string } | null>(lastSessionRaw, null);
378
+ let lastSession: { ts: string; summary: string } | null = null;
379
+ if (lastSessionRaw) {
380
+ try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ }
381
+ }
387
382
 
388
383
  // Open failures
389
384
  const openFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial');
@@ -514,24 +509,13 @@ export async function handleTeamTool(
514
509
  issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
515
510
  }
516
511
 
517
- const divider = '─'.repeat(48);
518
- const lines = [
519
- `🩺 **Brain Doctor**`,
520
- `\`${divider}\``,
521
- '',
522
- '**📊 Health Checks**',
523
- ...checks.map(c => ' ' + c),
524
- '',
525
- ];
512
+ const lines = ['🩺 **Brain Doctor Report**', '', ...checks.map(c => ' ' + c), ''];
526
513
  if (issues.length > 0) {
527
- lines.push(`**🔧 Action Items** (${issues.length} issue${issues.length > 1 ? 's' : ''})`);
528
- issues.forEach((item, idx) => lines.push(` ${idx + 1}. ${item}`));
514
+ lines.push('**Issues to fix:**');
515
+ for (const i of issues) lines.push(' ' + i);
529
516
  lines.push('');
530
- lines.push(`_Fix the action items above, then re-run \`brain_doctor\` to verify._`);
531
517
  } else {
532
- lines.push('**✅ All checks passed** — Brain is healthy!');
533
- lines.push('');
534
- lines.push('_Keep the brain sharp: `session_start` → work → `learn_from_attempts` → `session_end`_');
518
+ lines.push(' 🎉 Brain looks healthy! Keep calling session_start/session_end.');
535
519
  }
536
520
  return lines.join('\n');
537
521
  }
@@ -552,11 +536,7 @@ export async function handleTeamTool(
552
536
  .filter((l): l is NonNullable<typeof l> => l !== null && new Date(l.ts).getTime() <= cutoff)
553
537
  .sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
554
538
 
555
- if (before.length === 0) {
556
- const first = safeJsonParse(all[0] ?? null, null as null | { ts?: string });
557
- const earliest = first?.ts ? new Date(first.ts).toLocaleDateString('de-DE') : 'unknown';
558
- return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${earliest}.`;
559
- }
539
+ if (before.length === 0) return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${new Date(JSON.parse(all[0]).ts).toLocaleDateString('de-DE')}.`;
560
540
 
561
541
  const lines = [
562
542
  `🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
@@ -583,7 +563,7 @@ export async function handleTeamTool(
583
563
  const raw = await redis.get(depKey);
584
564
  if (!raw) return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
585
565
 
586
- const topics: string[] = safeJsonParse<string[]>(raw, []);
566
+ const topics: string[] = JSON.parse(raw);
587
567
  const lines = [
588
568
  `🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
589
569
  '',
@@ -592,8 +572,7 @@ export async function handleTeamTool(
592
572
  for (const t of topics) {
593
573
  const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
594
574
  if (!lessonRaw) { lines.push(` • \`${t}\` _(lesson deleted)_`); continue; }
595
- const lesson = safeJsonParse(lessonRaw, null as null | { outcome?: string; severity?: string; needs_review?: boolean });
596
- if (!lesson) { lines.push(` • \`${t}\` _(lesson corrupted)_`); continue; }
575
+ const lesson = JSON.parse(lessonRaw) as { outcome?: string; severity?: string; needs_review?: boolean };
597
576
  const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
598
577
  const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
599
578
  lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
@@ -635,8 +614,8 @@ export async function handleTeamTool(
635
614
  // Preserve recall_count on update
636
615
  const existing = await redis.get(key);
637
616
  if (existing) {
638
- const prev = safeJsonParse(existing, null as null | { recall_count?: number });
639
- record.recall_count = prev?.recall_count ?? 0;
617
+ const prev = JSON.parse(existing) as { recall_count?: number };
618
+ record.recall_count = prev.recall_count ?? 0;
640
619
  }
641
620
  await redis.set(key, JSON.stringify(record));
642
621
  return `🌐 **Global lesson stored**: \`${topic}\`\n\n${lesson}\n\nRecallable from any project via \`global_recall(topic="${topic}")\`.`;
@@ -656,12 +635,10 @@ export async function handleTeamTool(
656
635
 
657
636
  type GlobalLesson = { topic: string; lesson: string; severity?: string; ts: string; recall_count?: number };
658
637
  let lessons: GlobalLesson[] = [];
659
- if (keys.length > 0) {
660
- const raws = await redis.mget(...keys);
661
- for (const raw of raws) {
662
- const l = safeJsonParse<GlobalLesson | null>(raw ?? null, null);
663
- if (l) lessons.push(l);
664
- }
638
+ for (const k of keys) {
639
+ const raw = await redis.get(k);
640
+ if (!raw) continue;
641
+ try { lessons.push(JSON.parse(raw) as GlobalLesson); } catch { /* skip */ }
665
642
  }
666
643
 
667
644
  if (topic) {
@@ -678,8 +655,7 @@ export async function handleTeamTool(
678
655
  const k = `cachly:global:lesson:${l.topic}`;
679
656
  const raw = await redis.get(k);
680
657
  if (raw) {
681
- const rec = safeJsonParse(raw, null as null | { recall_count?: number });
682
- if (!rec) continue;
658
+ const rec = JSON.parse(raw) as { recall_count?: number };
683
659
  rec.recall_count = (rec.recall_count ?? 0) + 1;
684
660
  await redis.set(k, JSON.stringify(rec));
685
661
  }
package/src/index.ts CHANGED
@@ -52,22 +52,19 @@ import { Redis } from 'ioredis';
52
52
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
53
53
  let JWT = process.env.CACHLY_JWT ?? '';
54
54
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
55
- const CURRENT_VERSION = '0.10.0';
55
+ const CURRENT_VERSION = '0.9.17';
56
56
 
57
57
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
58
58
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
59
59
  // When neither is set, we auto-fetch the first running instance once per process.
60
60
  let _defaultInstanceId: string = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
61
- // Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
62
- let _defaultInstanceLastAttempt = 0;
61
+ let _defaultInstanceFetched = false;
63
62
 
64
63
  async function resolveDefaultInstanceId(): Promise<string> {
65
64
  if (_defaultInstanceId) return _defaultInstanceId;
65
+ if (_defaultInstanceFetched) return '';
66
+ _defaultInstanceFetched = true;
66
67
  if (!JWT) return '';
67
- // Cooldown: don't hammer the API on every tool call after a transient failure.
68
- const now = Date.now();
69
- if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000) return '';
70
- _defaultInstanceLastAttempt = now;
71
68
  try {
72
69
  const res = await fetch(`${API_URL}/api/v1/instances`, {
73
70
  headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
@@ -75,15 +72,9 @@ async function resolveDefaultInstanceId(): Promise<string> {
75
72
  });
76
73
  if (!res.ok) return '';
77
74
  const data = await res.json() as { data?: Array<{ id: string; status: string }> };
78
- const instances = (data?.data ?? []) as Array<{ id: string; status: string }>;
79
- // Prefer running, fall back to provisioning so the ID is resolved even during startup.
80
- const best = instances.find(i => i.status === 'running') ?? instances.find(i => i.status === 'provisioning');
81
- if (best) {
82
- _defaultInstanceId = best.id;
83
- _defaultInstanceLastAttempt = 0;
84
- return _defaultInstanceId;
85
- }
86
- } catch { /* transient error — will retry after cooldown */ }
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 */ }
87
78
  return '';
88
79
  }
89
80
 
@@ -158,55 +149,24 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
158
149
  JWT = apiKey;
159
150
  setEmbedJwt(apiKey); // keep embeddings.ts in sync
160
151
  _deviceFlow = null;
161
- // Auto-provision: find or create the user's brain instance.
162
- _defaultInstanceLastAttempt = 0;
152
+ // Auto-provision instance
153
+ _defaultInstanceFetched = false;
163
154
  await resolveDefaultInstanceId();
164
155
  if (!_defaultInstanceId) {
156
+ // Try auto-provision
165
157
  try {
166
158
  const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
167
159
  method: 'POST',
168
160
  headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
169
- signal: AbortSignal.timeout(12000),
161
+ signal: AbortSignal.timeout(10000),
170
162
  });
171
163
  if (autoRes.ok) {
172
- const body = await autoRes.json() as {
173
- instance?: { id: string; status?: string };
174
- instance_id?: string;
175
- status?: string;
176
- };
164
+ const body = await autoRes.json() as { instance?: { id: string }; instance_id?: string };
177
165
  const id = body.instance?.id ?? body.instance_id;
178
166
  if (id) _defaultInstanceId = id;
179
167
  }
180
168
  } catch { /* non-fatal */ }
181
169
  }
182
- // Give a provisioning instance a head-start so getConnection's wait is shorter.
183
- // The instance is almost always ready by the time the user's tool re-enters.
184
- if (_defaultInstanceId) {
185
- try {
186
- const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
187
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
188
- signal: AbortSignal.timeout(4000),
189
- });
190
- if (checkRes.ok) {
191
- const checkInst = await checkRes.json() as { status?: string };
192
- if (checkInst.status === 'provisioning') {
193
- // Wait up to 20s here so the re-entered tool call lands on a running instance.
194
- const pDeadline = Date.now() + 20_000;
195
- while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
196
- await new Promise(r => setTimeout(r, 3000));
197
- const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
198
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
199
- signal: AbortSignal.timeout(4000),
200
- }).catch(() => null);
201
- if (rr?.ok) {
202
- const ri = await rr.json() as { status?: string };
203
- (checkInst as { status?: string }).status = ri.status;
204
- } else { break; }
205
- }
206
- }
207
- }
208
- } catch { /* non-fatal — getConnection will handle the wait if still provisioning */ }
209
- }
210
170
  return 'done';
211
171
  }
212
172
  if (data.error === 'slow_down') flow.pollInterval = Math.min(flow.pollInterval + 2000, 15000);
@@ -256,48 +216,18 @@ interface SemanticSearchResponse {
256
216
  const pool = new Map<string, Redis>();
257
217
 
258
218
  async function getConnection(instance_id: string): Promise<Redis> {
259
- if (!instance_id) {
260
- throw new McpError(
261
- ErrorCode.InvalidRequest,
262
- 'No instance_id provided and no running instance could be resolved automatically.\n\n' +
263
- '• Set CACHLY_BRAIN_INSTANCE_ID in your MCP config, or\n' +
264
- '• Pass instance_id explicitly to the tool, or\n' +
265
- '• Run `list_instances` to see your available instances.'
266
- );
267
- }
268
-
269
219
  if (pool.has(instance_id)) return pool.get(instance_id)!;
270
220
 
271
- // Fetch instance, waiting up to 25 s if it is still provisioning.
272
- // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
273
- // all happen in quick succession and the instance isn't running yet.
274
- let inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
275
- if (inst.status === 'provisioning') {
276
- const deadline = Date.now() + 25_000;
277
- while (inst.status === 'provisioning' && Date.now() < deadline) {
278
- await new Promise(r => setTimeout(r, 3000));
279
- inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
280
- }
281
- }
282
-
221
+ const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
283
222
  if (inst.status !== 'running') {
284
- // Telemetry: unreachable instance so the team can proactively investigate
285
- void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
286
- method: 'POST',
287
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
288
- body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
289
- signal: AbortSignal.timeout(3000),
290
- }).catch(() => {});
291
- const hint = inst.status === 'provisioning'
292
- ? `⏳ Brain instance "${inst.name}" is still starting up.\n\nThis usually finishes within 30 seconds. Please try again in a moment.`
293
- : `Brain instance "${inst.name}" is not reachable (status: ${inst.status}).\n\n` +
294
- `• View your instance at: https://cachly.dev/instances\n` +
295
- `• Run \`get_api_status\` for a full diagnostic.`;
296
- throw new McpError(ErrorCode.InvalidRequest, hint);
223
+ throw new McpError(
224
+ ErrorCode.InvalidRequest,
225
+ `Instance "${inst.name}" is not running (status: ${inst.status}). ` +
226
+ `It may still be provisioning or awaiting payment.`
227
+ );
297
228
  }
298
229
  if (!inst.host || !inst.port) {
299
- throw new McpError(ErrorCode.InternalError,
300
- `Instance "${inst.name}" is running but has no host/port — contact support@cachly.dev.`);
230
+ throw new McpError(ErrorCode.InternalError, `Instance "${inst.name}" has no host/port yet.`);
301
231
  }
302
232
 
303
233
  // Fetch connection details (includes password) from the dedicated endpoint.
@@ -310,7 +240,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
310
240
  password = conn.password ?? undefined;
311
241
  tlsEnabled = conn.tls_enabled !== false;
312
242
  } catch {
313
- // Fallback: use TLS flag from instance metadata
243
+ // Fallback: no password, use TLS default from instance
314
244
  }
315
245
 
316
246
  const client = new Redis({
@@ -318,33 +248,26 @@ async function getConnection(instance_id: string): Promise<Redis> {
318
248
  port: inst.port,
319
249
  password: password || undefined,
320
250
  ...(tlsEnabled ? { tls: {} } : {}),
321
- lazyConnect: true,
251
+ lazyConnect: true, // don't auto-connect — we connect explicitly below
322
252
  enableReadyCheck: true,
323
- connectTimeout: 8000,
253
+ connectTimeout: 5000,
324
254
  retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
325
255
  });
326
256
 
327
- const evict = () => pool.delete(instance_id);
328
- client.on('error', evict);
329
- client.on('end', evict);
257
+ client.on('error', () => {
258
+ pool.delete(instance_id); // remove stale connection on error
259
+ });
330
260
 
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.
331
263
  try {
332
264
  await client.connect();
333
265
  } catch (err: unknown) {
334
266
  client.disconnect();
335
267
  const msg = err instanceof Error ? err.message : String(err);
336
- void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
337
- method: 'POST',
338
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
339
- body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
340
- signal: AbortSignal.timeout(3000),
341
- }).catch(() => {});
342
268
  throw new McpError(
343
269
  ErrorCode.InternalError,
344
- `Could not reach Brain instance "${inst.name}" (${inst.host}:${inst.port}).\n\n` +
345
- `• Check your network connection\n` +
346
- `• Run \`get_api_status\` to verify instance status\n` +
347
- `• Technical detail: ${msg}`
270
+ `Could not connect to instance "${inst.name}" (${inst.host}:${inst.port}): ${msg}`
348
271
  );
349
272
  }
350
273
 
@@ -614,44 +537,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
614
537
  authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
615
538
  }
616
539
 
617
- // List instances + connectivity
618
- let instanceInfo = '';
619
- try {
620
- const listRes = await apiFetch<{ data: Instance[] }>('/api/v1/instances');
621
- const instances = listRes.data ?? [];
622
- if (instances.length === 0) {
623
- instanceInfo = '\n\n🧠 **Brain Instances:** none — create one at https://cachly.dev/instances';
624
- } else {
625
- const lines: string[] = ['\n\n🧠 **Brain Instances:**'];
626
- for (const inst of instances) {
627
- const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
628
- const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
629
- lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
630
- if (inst.status === 'running' && inst.host) {
631
- // Try a quick ping on the cached connection
632
- const pooled = pool.get(inst.id);
633
- if (pooled) {
634
- try {
635
- await pooled.ping();
636
- lines.push(` 💓 Redis: connected`);
637
- } catch {
638
- lines.push(` ⚠️ Redis: pooled but ping failed — will reconnect on next use`);
639
- }
640
- } else {
641
- lines.push(` 💤 Redis: not connected yet (connects on first tool use)`);
642
- }
643
- } else if (inst.status === 'provisioning') {
644
- lines.push(` ⏳ Still provisioning — check back in ~30 s`);
645
- } else if (inst.status !== 'running') {
646
- lines.push(` ❌ Not reachable (status: ${inst.status})`);
647
- }
648
- }
649
- instanceInfo = lines.join('\n');
650
- }
651
- } catch (e) {
652
- instanceInfo = `\n\n🧠 **Brain Instances:** could not fetch — ${(e as Error).message}`;
653
- }
654
-
655
540
  return [
656
541
  `📡 **cachly API Status**`,
657
542
  ``,
@@ -660,7 +545,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
660
545
  ``,
661
546
  `🔑 **Auth:**`,
662
547
  authInfo,
663
- instanceInfo,
664
548
  ].join('\n');
665
549
  }
666
550
 
@@ -698,9 +582,8 @@ async function autoStartSession(instanceId: string): Promise<void> {
698
582
  _autoSessionInstanceId = instanceId;
699
583
  try {
700
584
  await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
701
- } catch { /* non-fatal — session tracking is a best-effort feature */ } finally {
702
- _autoSessionStarting = null;
703
- }
585
+ } catch { /* non-fatal — session tracking is a best-effort feature */ }
586
+ _autoSessionStarting = null;
704
587
  })();
705
588
  await _autoSessionStarting;
706
589
 
@@ -749,9 +632,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
749
632
  // Skip session management tools to avoid recursion.
750
633
  const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
751
634
  if (!sessionTools.has(name) && !_autoSessionStarted && !_autoSessionStarting) {
752
- const instanceId = ((args as Record<string, unknown>)?.instance_id as string | undefined)
753
- || _defaultInstanceId
754
- || process.env.CACHLY_BRAIN_INSTANCE_ID;
635
+ const instanceId = (args as Record<string, unknown>)?.instance_id as string | undefined;
755
636
  if (instanceId) {
756
637
  await autoStartSession(instanceId).catch(() => undefined);
757
638
  }
package/src/tools.ts CHANGED
@@ -437,11 +437,10 @@ const TOOLS = [
437
437
  {
438
438
  name: 'get_api_status',
439
439
  description:
440
- 'Full diagnostic for your cachly Brain — call this FIRST whenever anything is not working. ' +
441
- 'Returns: API reachability, JWT validity + expiry, your user ID, ' +
442
- 'all Brain instances with live status (🟢 running / 🟡 provisioning / 🔴 stopped), ' +
443
- 'Redis ping on the active connection, and actionable fix steps for every issue found. ' +
444
- 'Workflow: run get_api_status → read the issue it flags → fix it → retry your tool.',
440
+ 'Check the cachly API health and your authentication status. ' +
441
+ 'Returns whether the JWT is valid, your user ID (sub claim), ' +
442
+ 'token expiry, and the auth provider (keycloak). ' +
443
+ 'Use this to debug connection issues or verify your CACHLY_JWT is correct.',
445
444
  inputSchema: { type: 'object', properties: {}, required: [] },
446
445
  },
447
446
  // ── Thinking/Context Cache (for AI assistants) ────────────────────────────