@cachly-dev/mcp-server 0.10.45 โ†’ 0.10.47

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.
@@ -417,27 +417,8 @@ export async function handleBrainTool(
417
417
  ? `๐Ÿ“Œ **You've looked this up 3 times.** Consider pinning it for instant access: add \`pinned: true\` via \`learn_from_attempts\` to always surface it first.`
418
418
  : '';
419
419
 
420
- // โ”€โ”€ Trust signal (today-safe consensus layer) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
421
- // A lesson recalled many times โ€” or confirmed by multiple distinct
422
- // authors โ€” has proven its value. Surface that as social proof.
423
- const lessonAuthors = (lesson as { authors?: string[]; author?: string }).authors
424
- ?? ((lesson as { author?: string }).author ? [(lesson as { author?: string }).author!] : []);
425
- const distinctAuthors = [...new Set(lessonAuthors.filter(Boolean))];
426
- const rc = updatedLesson.recall_count;
427
- let trustBadge = '';
428
- if (distinctAuthors.length >= 2 && rc >= 5) {
429
- trustBadge = `๐Ÿ† **Battle-tested** โ€” recalled ${rc}ร— ยท verified by ${distinctAuthors.length} developers. Trust this.`;
430
- } else if (rc >= 10) {
431
- trustBadge = `๐Ÿ† **Battle-tested** โ€” recalled ${rc}ร—. This is one of your most-proven solutions.`;
432
- } else if (distinctAuthors.length >= 2) {
433
- trustBadge = `โœ… **Team-verified** โ€” confirmed by ${distinctAuthors.length} developers.`;
434
- } else if (rc >= 5) {
435
- trustBadge = `โœ… **Proven** โ€” recalled ${rc}ร— without contradiction.`;
436
- }
437
-
438
420
  return [
439
421
  rememberWhen,
440
- trustBadge,
441
422
  `${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} ยท recalled ${updatedLesson.recall_count}ร—`,
442
423
  ``,
443
424
  `**What worked:** ${lesson.what_worked}`,
@@ -502,22 +483,6 @@ export async function handleBrainTool(
502
483
  10,
503
484
  );
504
485
 
505
- // Increment recall_count on matched lessons (fire-and-forget โ€” same as recall_best_solution).
506
- // This ensures the dashboard metric, Proven Laws, and trust badges reflect real smart_recall usage.
507
- const lessonMatches = kwMatches.filter(m => m.key.startsWith('cachly:lesson:best:'));
508
- for (const m of lessonMatches.slice(0, 5)) {
509
- const existing = await redis.get(m.key).catch(() => null);
510
- if (existing) {
511
- const lesson = safeJsonParse(existing, null as null | { recall_count?: number; [k: string]: unknown });
512
- if (lesson) {
513
- const updated = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1, verified_at: new Date().toISOString() };
514
- redis.set(m.key, JSON.stringify(updated)).catch(() => {});
515
- const savedMins = (lesson.severity as string) === 'critical' ? 240 : (lesson.severity as string) === 'major' ? 60 : 30;
516
- redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
517
- }
518
- }
519
- }
520
-
521
486
  const lines: string[] = [`๐Ÿง  **Smart Recall** for: _"${query}"_\n`];
522
487
 
523
488
  // Show sub-query info if multi-topic was detected
@@ -932,31 +897,6 @@ export async function handleBrainTool(
932
897
  // Brain health
933
898
  lines.push(`๐Ÿ“Š **Brain:** ${lessons.length} lessons ยท ${ctxCount} context entries`, '');
934
899
 
935
- // โ”€โ”€ ๐Ÿ“Œ Proven Laws โ€” auto-crystallized lessons (quick win 3) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
936
- // A lesson that has proven itself (recalled โ‰ฅ CRYSTALLIZE_RECALLS times) or
937
- // was explicitly pinned becomes a "law" โ€” surfaced at the top of every
938
- // briefing so the most battle-tested knowledge is never buried. This is
939
- // the today-safe version of swarm crystallization: enough confirmations
940
- // auto-promote a lesson without any manual step.
941
- {
942
- type LessonPin = typeof lessons[0] & { pinned?: boolean };
943
- const CRYSTALLIZE_RECALLS = 5;
944
- const laws = (lessons as LessonPin[])
945
- .filter(l => l.outcome === 'success' && (l.pinned === true || (l.recall_count ?? 0) >= CRYSTALLIZE_RECALLS))
946
- .sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0))
947
- .slice(0, 5);
948
- if (laws.length > 0) {
949
- lines.push(`๐Ÿ“Œ **Proven Laws** (auto-crystallized โ€” your most-trusted knowledge):`);
950
- for (const l of laws) {
951
- const sev = l.severity === 'critical' ? '๐Ÿ”ด' : l.severity === 'major' ? '๐ŸŸก' : '';
952
- const rc = l.recall_count ?? 0;
953
- const why = l.pinned === true ? 'pinned' : `recalled ${rc}ร—`;
954
- lines.push(` ๐Ÿ†${sev} \`${l.topic}\` _(${why})_ โ€” ${l.what_worked.slice(0, 100)}`);
955
- }
956
- lines.push('');
957
- }
958
- }
959
-
960
900
  // โ”€โ”€ Layer 7: MCM Domain Coverage Map โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
961
901
  if (lessons.length >= 3) {
962
902
  const domainMap = new Map<string, { total: number; success: number; critical: number }>();
@@ -1056,14 +996,8 @@ export async function handleBrainTool(
1056
996
  for (const l of topRecalled) {
1057
997
  const emoji = l.outcome === 'success' ? 'โœ…' : l.outcome === 'partial' ? 'โš ๏ธ' : 'โŒ';
1058
998
  const sev = l.severity === 'critical' ? '๐Ÿ”ด' : l.severity === 'major' ? '๐ŸŸก' : '';
1059
- const rc = l.recall_count ?? 0;
1060
- const la = (l as { authors?: string[]; author?: string }).authors
1061
- ?? ((l as { author?: string }).author ? [(l as { author?: string }).author!] : []);
1062
- const nAuthors = new Set(la.filter(Boolean)).size;
1063
- const trust = (nAuthors >= 2 && rc >= 5) || rc >= 10 ? ' ๐Ÿ†'
1064
- : nAuthors >= 2 || rc >= 5 ? ' โœ…' : '';
1065
- const rct = rc > 1 ? ` _(${rc}ร— recalled)_` : '';
1066
- lines.push(` ${emoji}${sev}${trust} \`${l.topic}\`${rct} โ€” ${l.what_worked.slice(0, 100)}`);
999
+ const rct = l.recall_count && l.recall_count > 1 ? ` _(${l.recall_count}ร— recalled)_` : '';
1000
+ lines.push(` ${emoji}${sev} \`${l.topic}\`${rct} โ€” ${l.what_worked.slice(0, 100)}`);
1067
1001
  }
1068
1002
  lines.push('');
1069
1003
  }
@@ -1131,63 +1065,27 @@ export async function handleBrainTool(
1131
1065
  }
1132
1066
 
1133
1067
  // โ”€โ”€ ๐Ÿ”ฎ Predictive Pre-Warning โ€” intent-based danger detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1134
- // Fires BEFORE work starts. Uses explicit focus when given; otherwise
1135
- // derives the likely work area from the last session's changed files +
1136
- // summary (you usually keep working where you left off). This makes the
1137
- // warning fire even when the caller forgets to pass `focus`.
1138
- {
1068
+ // Fires BEFORE work starts. If focus area has known failure patterns โ†’ warn loudly.
1069
+ if (focusTerms.length > 0) {
1139
1070
  type LessonAny = typeof lessons[0] & { author?: string; tags?: string[] };
1140
-
1141
- // Common path noise / extensions to drop when deriving terms from files.
1142
- const PATH_NOISE = new Set([
1143
- 'src', 'lib', 'test', 'tests', 'dist', 'index', 'main', 'app',
1144
- 'internal', 'pkg', 'cmd', 'node_modules', 'components', 'utils',
1145
- 'ts', 'tsx', 'js', 'jsx', 'go', 'py', 'rs', 'java', 'json', 'yaml', 'yml',
1146
- ]);
1147
- const deriveTermsFromFiles = (files: string[]): string[] => {
1148
- const terms = new Set<string>();
1149
- for (const f of files) {
1150
- for (const seg of f.toLowerCase().split(/[/\\._-]/)) {
1151
- if (seg.length > 3 && !PATH_NOISE.has(seg)) terms.add(seg);
1152
- }
1153
- }
1154
- return [...terms];
1155
- };
1156
-
1157
- let warnTerms = focusTerms;
1158
- let warnLabel = focus;
1159
- let derived = false;
1160
- if (warnTerms.length === 0 && lastSession) {
1161
- const fromFiles = deriveTermsFromFiles(lastSession.files_changed ?? []);
1162
- const fromSummary = (lastSession.summary ?? '').toLowerCase()
1163
- .replace(/[^a-z0-9\s:_-]/g, ' ').split(/\s+/).filter(t => t.length > 3 && !PATH_NOISE.has(t));
1164
- warnTerms = [...new Set([...fromFiles, ...fromSummary])];
1165
- warnLabel = 'where you left off last session';
1166
- derived = true;
1167
- }
1168
-
1169
- if (warnTerms.length > 0) {
1170
- const dangerLessons = (lessons as LessonAny[]).filter(l => {
1171
- if (l.outcome === 'success') return false;
1172
- const topicCategory = l.topic.split(':')[0];
1173
- return warnTerms.some(term =>
1174
- l.topic.toLowerCase().includes(term) ||
1175
- topicCategory === term ||
1176
- (l.tags ?? []).some((t: string) => t.toLowerCase() === term),
1177
- );
1178
- });
1179
- if (dangerLessons.length >= 1) {
1180
- const headline = derived
1181
- ? ` You're likely to continue **${warnLabel}** โ€” ${dangerLessons.length} known pitfall${dangerLessons.length > 1 ? 's' : ''} there:`
1182
- : ` Known pitfalls for **"${warnLabel}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`;
1183
- const warning = [
1184
- `๐Ÿšจ **PRE-WARNING** โ€” Read this BEFORE starting:`,
1185
- headline,
1186
- ...dangerLessons.slice(0, 3).map(l => ` โŒ \`${l.topic}\` โ€” ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
1187
- '',
1188
- ];
1189
- lines.splice(2, 0, ...warning); // after '๐Ÿง  **Session Briefing**' + empty line
1190
- }
1071
+ const dangerLessons = (lessons as LessonAny[]).filter(l => {
1072
+ if (l.outcome === 'success') return false;
1073
+ const topicCategory = l.topic.split(':')[0];
1074
+ return focusTerms.some(term =>
1075
+ l.topic.toLowerCase().includes(term) ||
1076
+ topicCategory === term ||
1077
+ (l.tags ?? []).some((t: string) => t.toLowerCase() === term),
1078
+ );
1079
+ });
1080
+ if (dangerLessons.length >= 1) {
1081
+ // Insert warning block right after the title line (index 1 = blank line after title)
1082
+ const warning = [
1083
+ `๐Ÿšจ **PRE-WARNING** โ€” Read this BEFORE starting:`,
1084
+ ` Known pitfalls for **"${focus}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`,
1085
+ ...dangerLessons.slice(0, 3).map(l => ` โŒ \`${l.topic}\` โ€” ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
1086
+ '',
1087
+ ];
1088
+ lines.splice(2, 0, ...warning); // after '๐Ÿง  **Session Briefing**' + empty line
1191
1089
  }
1192
1090
  }
1193
1091
 
package/src/index.ts CHANGED
@@ -54,13 +54,23 @@ import {
54
54
  McpError,
55
55
  } from '@modelcontextprotocol/sdk/types.js';
56
56
  import { Redis } from 'ioredis';
57
+ import { AsyncLocalStorage } from 'node:async_hooks';
57
58
 
58
59
  // โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
59
60
 
60
61
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
61
62
  let JWT = process.env.CACHLY_JWT ?? '';
62
63
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
63
- const CURRENT_VERSION = '0.10.44';
64
+ const CURRENT_VERSION = '0.10.47';
65
+
66
+ // โ”€โ”€ Per-request context (multi-tenant HTTP / Smithery) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
67
+ // In stdio mode the module-level JWT/instance globals apply.
68
+ // In HTTP mode, the request handler runs each request in an ALS scope that
69
+ // shadows JWT + instance ID with per-user values extracted from headers.
70
+ interface ReqCtx { jwt: string; instanceId: string; }
71
+ const reqCtx = new AsyncLocalStorage<ReqCtx>();
72
+ function getJWT(): string { return reqCtx.getStore()?.jwt || JWT; }
73
+ function getDefaultInstanceId(): string { return reqCtx.getStore()?.instanceId || _defaultInstanceId; }
64
74
 
65
75
  // โ”€โ”€ Default Instance Resolution (for Smithery & single-credential setups) โ”€โ”€โ”€โ”€
66
76
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
@@ -70,15 +80,18 @@ let _defaultInstanceId: string = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
70
80
  let _defaultInstanceLastAttempt = 0;
71
81
 
72
82
  async function resolveDefaultInstanceId(): Promise<string> {
83
+ // In an HTTP request scope the instance ID is fixed by the caller's header.
84
+ const scopedId = reqCtx.getStore()?.instanceId;
85
+ if (scopedId) return scopedId;
73
86
  if (_defaultInstanceId) return _defaultInstanceId;
74
- if (!JWT) return '';
87
+ if (!getJWT()) return '';
75
88
  // Cooldown: don't hammer the API on every tool call after a transient failure.
76
89
  const now = Date.now();
77
90
  if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000) return '';
78
91
  _defaultInstanceLastAttempt = now;
79
92
  try {
80
93
  const res = await fetch(`${API_URL}/api/v1/instances`, {
81
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
94
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
82
95
  signal: AbortSignal.timeout(5000),
83
96
  });
84
97
  if (!res.ok) return '';
@@ -174,7 +187,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
174
187
  try {
175
188
  const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
176
189
  method: 'POST',
177
- headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
190
+ headers: { Authorization: `Bearer ${getJWT()}`, 'Content-Type': 'application/json' },
178
191
  signal: AbortSignal.timeout(12000),
179
192
  });
180
193
  if (autoRes.ok) {
@@ -189,7 +202,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
189
202
  // Log so telemetry can detect free-tier auto-provision failures
190
203
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
191
204
  method: 'POST',
192
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${JWT}` },
205
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getJWT()}` },
193
206
  body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
194
207
  signal: AbortSignal.timeout(3000),
195
208
  }).catch(() => {});
@@ -201,7 +214,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
201
214
  if (_defaultInstanceId) {
202
215
  try {
203
216
  const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
204
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
217
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
205
218
  signal: AbortSignal.timeout(4000),
206
219
  });
207
220
  if (checkRes.ok) {
@@ -212,7 +225,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
212
225
  while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
213
226
  await new Promise(r => setTimeout(r, 3000));
214
227
  const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
215
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
228
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
216
229
  signal: AbortSignal.timeout(4000),
217
230
  }).catch(() => null);
218
231
  if (rr?.ok) {
@@ -283,7 +296,19 @@ async function getConnection(instance_id: string): Promise<Redis> {
283
296
  );
284
297
  }
285
298
 
286
- if (pool.has(instance_id)) return pool.get(instance_id)!;
299
+ if (pool.has(instance_id)) {
300
+ const cached = pool.get(instance_id)!;
301
+ // Verify the pooled connection is still alive before reusing it.
302
+ // ioredis 'error' eviction is async โ€” a connection can be in the pool
303
+ // but already internally broken (status 'reconnecting' or 'end').
304
+ const status = (cached as any).status as string | undefined;
305
+ if (status === 'ready' || status === 'connect') {
306
+ return cached;
307
+ }
308
+ // Stale โ€” evict and fall through to reconnect.
309
+ pool.delete(instance_id);
310
+ cached.disconnect();
311
+ }
287
312
 
288
313
  // Fetch instance, waiting up to 25 s if it is still provisioning.
289
314
  // This covers the zero-friction path: device-flow auth โ†’ auto-provision โ†’ first tool call
@@ -301,7 +326,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
301
326
  // Telemetry: unreachable instance so the team can proactively investigate
302
327
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
303
328
  method: 'POST',
304
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
329
+ headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
305
330
  body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
306
331
  signal: AbortSignal.timeout(3000),
307
332
  }).catch(() => {});
@@ -365,14 +390,45 @@ async function getConnection(instance_id: string): Promise<Redis> {
365
390
  client.on('error', evict);
366
391
  client.on('end', evict);
367
392
 
368
- try {
369
- await client.connect();
370
- } catch (err: unknown) {
393
+ // Retry up to 3 times with 2s backoff. Covers the window between K8s PodReady
394
+ // and Valkey actually accepting connections (RDB/AOF load, ~1-3s on cold start).
395
+ let connectErr: unknown;
396
+ for (let attempt = 0; attempt < 3; attempt++) {
397
+ try {
398
+ if (attempt > 0) {
399
+ await new Promise(r => setTimeout(r, 2000 * attempt));
400
+ // Fresh client on retry โ€” ioredis doesn't allow reconnect after explicit disconnect.
401
+ client.disconnect();
402
+ const retryClient = new Redis({
403
+ host: inst.host,
404
+ port: inst.port,
405
+ password: password || undefined,
406
+ ...(tlsEnabled ? { tls: {} } : {}),
407
+ lazyConnect: true,
408
+ enableReadyCheck: true,
409
+ connectTimeout: 8000,
410
+ retryStrategy: () => null,
411
+ });
412
+ retryClient.on('error', () => pool.delete(instance_id));
413
+ retryClient.on('end', () => pool.delete(instance_id));
414
+ await retryClient.connect();
415
+ pool.set(instance_id, retryClient);
416
+ return retryClient;
417
+ }
418
+ await client.connect();
419
+ connectErr = undefined;
420
+ break;
421
+ } catch (err) {
422
+ connectErr = err;
423
+ }
424
+ }
425
+
426
+ if (connectErr) {
371
427
  client.disconnect();
372
- const msg = err instanceof Error ? err.message : String(err);
428
+ const msg = connectErr instanceof Error ? connectErr.message : String(connectErr);
373
429
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
374
430
  method: 'POST',
375
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
431
+ headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
376
432
  body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
377
433
  signal: AbortSignal.timeout(3000),
378
434
  }).catch(() => {});
@@ -392,14 +448,14 @@ async function getConnection(instance_id: string): Promise<Redis> {
392
448
  // โ”€โ”€ API helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
393
449
 
394
450
  async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
395
- checkJwt(JWT);
451
+ checkJwt(getJWT());
396
452
  const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
397
453
  const res = await fetch(`${API_URL}${path}`, {
398
454
  ...options,
399
455
  signal: AbortSignal.timeout(timeoutMs),
400
456
  headers: {
401
457
  'Content-Type': 'application/json',
402
- Authorization: `Bearer ${JWT}`,
458
+ Authorization: `Bearer ${getJWT()}`,
403
459
  ...options.headers,
404
460
  },
405
461
  });
@@ -454,7 +510,7 @@ function sendFunnelEvent(event: string, extra?: Record<string, unknown>): void {
454
510
  headers: { 'Content-Type': 'application/json' },
455
511
  body: JSON.stringify({
456
512
  event, version: CURRENT_VERSION, editor: detectEditor(),
457
- ...(JWT ? { jwt: JWT } : {}),
513
+ ...(getJWT() ? { jwt: getJWT() } : {}),
458
514
  ...extra,
459
515
  }),
460
516
  signal: AbortSignal.timeout(3000),
@@ -477,7 +533,7 @@ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
477
533
 
478
534
  async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
479
535
  // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
480
- if (!JWT) {
536
+ if (!getJWT()) {
481
537
  void sendAnonymousTelemetry(name);
482
538
 
483
539
  // โ”€โ”€ Zero-credential device flow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -557,14 +613,14 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
557
613
  // Delegate brain tools (learn, recall, session, etc.)
558
614
  const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
559
615
  if (brainResult !== null) {
560
- if (!_firstCallSuccessSent && JWT) {
616
+ if (!_firstCallSuccessSent && getJWT()) {
561
617
  _firstCallSuccessSent = true;
562
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
618
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
563
619
  }
564
620
  // Per-tool telemetry โ€” fires after every successful brain tool call.
565
621
  // api_key carries the cky_live_... token so the backend can resolve tenant + increment counters.
566
- const instanceId = (args.instance_id as string | undefined) ?? _defaultInstanceId ?? '';
567
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
622
+ const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
623
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
568
624
  if (name === 'recall_best_solution') {
569
625
  // recall_best_solution โ†’ increments BrainRecallCount + triggers level-up logic
570
626
  sendFunnelEvent('recall_best_solution', telemetryExtra);
@@ -625,9 +681,9 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
625
681
  // Delegate context tools (remember/recall/list/forget)
626
682
  const contextResult = await handleContextTool(name, args, getConnection, apiFetch);
627
683
  if (contextResult !== null) {
628
- if (!_firstCallSuccessSent && JWT) {
684
+ if (!_firstCallSuccessSent && getJWT()) {
629
685
  _firstCallSuccessSent = true;
630
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
686
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
631
687
  }
632
688
  return contextResult;
633
689
  }
@@ -653,8 +709,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
653
709
  if (syndicateResult !== null) {
654
710
  // brain_predict is in syndicate handler โ€” track telemetry here.
655
711
  if (name === 'brain_predict') {
656
- const instanceId = (args.instance_id as string | undefined) ?? _defaultInstanceId ?? '';
657
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
712
+ const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
713
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
658
714
  sendFunnelEvent('brain_predict', telemetryExtra);
659
715
  }
660
716
  return syndicateResult;
@@ -663,8 +719,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
663
719
  const fedbrainResult = await handleFedbrainTool(name, args, getConnection, apiFetch);
664
720
  if (fedbrainResult !== null) {
665
721
  // brain_from_git and brain_predict_failures are in fedbrain handler.
666
- const instanceId = (args.instance_id as string | undefined) ?? _defaultInstanceId ?? '';
667
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
722
+ const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
723
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
668
724
  if (name === 'brain_from_git') {
669
725
  sendFunnelEvent('brain_from_git', telemetryExtra);
670
726
  } else if (name === 'brain_predict_failures') {
@@ -691,7 +747,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
691
747
  }
692
748
 
693
749
  // Check JWT / auth
694
- if (!JWT) {
750
+ if (!getJWT()) {
695
751
  return [
696
752
  `๐Ÿ“ก **cachly API Status**`,
697
753
  ``,
@@ -706,7 +762,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
706
762
  // Decode JWT claims (inspection only, no verification)
707
763
  let authInfo = 'โŒ invalid JWT format';
708
764
  try {
709
- const parts = JWT.split('.');
765
+ const parts = getJWT().split('.');
710
766
  if (parts.length === 3) {
711
767
  const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
712
768
  sub?: string; exp?: number; iss?: string;
@@ -739,7 +795,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
739
795
  const lines: string[] = ['\n\n๐Ÿง  **Brain Instances:**'];
740
796
  for (const inst of instances) {
741
797
  const badge = inst.status === 'running' ? '๐ŸŸข' : inst.status === 'provisioning' ? '๐ŸŸก' : '๐Ÿ”ด';
742
- const defaultMark = inst.id === _defaultInstanceId ? ' โ† active' : '';
798
+ const defaultMark = inst.id === getDefaultInstanceId() ? ' โ† active' : '';
743
799
  lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) ยท ${inst.status}${defaultMark}`);
744
800
  if (inst.status === 'running' && inst.host) {
745
801
  // Try a quick ping on the cached connection
@@ -2156,7 +2212,6 @@ if (process.argv[2] === 'setup') {
2156
2212
  const CLIENT_ID = 'cachly-cli';
2157
2213
 
2158
2214
  console.log('Step 1: Sign in to cachly (free, no credit card)\n');
2159
- sendFunnelEvent('setup_auth_started');
2160
2215
 
2161
2216
  // Start device flow
2162
2217
  let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
@@ -2180,7 +2235,6 @@ if (process.argv[2] === 'setup') {
2180
2235
  console.error('Falling back: sign in at https://cachly.dev/setup-ai and paste your API token.\n');
2181
2236
  token = await ask(' Paste API token (cky_live_...): ');
2182
2237
  if (!token) { console.error('\nToken is required. Aborting.\n'); rl.close(); process.exit(1); }
2183
- sendFunnelEvent('setup_auth_completed');
2184
2238
  console.log('');
2185
2239
  deviceCode = ''; // mark as fallback so we skip polling
2186
2240
  }
@@ -2214,7 +2268,6 @@ if (process.argv[2] === 'setup') {
2214
2268
  if (tokenData.access_token) {
2215
2269
  token = tokenData.access_token;
2216
2270
  console.log(' \x1b[32mโœ“ Authorized!\x1b[0m\n');
2217
- sendFunnelEvent('setup_auth_completed');
2218
2271
  break;
2219
2272
  }
2220
2273
  // authorization_pending = keep polling; slow_down = increase interval
@@ -2331,7 +2384,6 @@ if (process.argv[2] === 'setup') {
2331
2384
  console.log(` (Run with --instance-id <id> to use a different one)\n`);
2332
2385
  }
2333
2386
  console.log(`โœ“ Instance: ${instance.name} (${instance.id.slice(0, 8)}โ€ฆ)\n`);
2334
- sendFunnelEvent('setup_instance_ready', { instance_id: instance.id });
2335
2387
 
2336
2388
  // โ”€โ”€ Step 3: Detect editors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2337
2389
  const cwd = process.cwd();
@@ -2577,7 +2629,7 @@ if (process.argv[2] === 'index') {
2577
2629
  const maxFiles = parseInt(flag('max-files') ?? '500', 10);
2578
2630
  const namespace = flag('namespace') ?? 'cachly:sem:code';
2579
2631
 
2580
- if (!instanceId || !JWT) {
2632
+ if (!instanceId || !getJWT()) {
2581
2633
  console.error('\nโŒ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
2582
2634
  console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
2583
2635
  console.error(' export CACHLY_JWT=<cky_live_...>');
@@ -2619,7 +2671,7 @@ if (process.argv[2] === 'learn-git') {
2619
2671
  const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
2620
2672
  const maxCommits = parseInt(flag('max-commits') ?? '50', 10);
2621
2673
 
2622
- if (!instanceId || !JWT) {
2674
+ if (!instanceId || !getJWT()) {
2623
2675
  console.error('\nโŒ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
2624
2676
  console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
2625
2677
  console.error(' export CACHLY_JWT=<cky_live_...>');
@@ -2654,7 +2706,8 @@ if (process.argv[2] === 'learn-git') {
2654
2706
  // actionable message in their editor's MCP log instead of silent failures.
2655
2707
  // Skip for CLI commands that intentionally run without credentials.
2656
2708
  const _cliNoAuthCommands = ['demo', 'share', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade'];
2657
- if (!JWT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
2709
+ // In multi-tenant HTTP mode (PORT set) we never need a startup JWT โ€” per-request auth.
2710
+ if (!JWT && !process.env.PORT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
2658
2711
  // No args + no JWT in a non-TTY context = running as MCP server without credentials.
2659
2712
  // Print actionable setup banner to stdout so it's captured by callers/tests.
2660
2713
  const banner =
@@ -2730,9 +2783,37 @@ const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
2730
2783
  if (httpPort) {
2731
2784
  // โ”€โ”€ HTTP mode (Streamable HTTP transport) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2732
2785
  // Used for Smithery URL deployment: PORT=3000 node dist/index.js
2786
+ // Multi-tenant: per-request JWT + instance ID resolved via:
2787
+ // 1. Authorization: Bearer <jwt> + X-Cachly-Instance-Id: <uuid>
2788
+ // 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
2789
+ // Falls back to module-level env-derived values when neither is provided.
2733
2790
  const { createServer } = await import('node:http');
2734
2791
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
2735
2792
  await server.connect(transport);
2793
+
2794
+ function extractReqCtx(req: import('node:http').IncomingMessage, url: URL): ReqCtx {
2795
+ let jwtVal = '';
2796
+ let instId = '';
2797
+ const auth = req.headers['authorization'];
2798
+ if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) {
2799
+ jwtVal = auth.slice(7).trim();
2800
+ }
2801
+ const hdrInst = req.headers['x-cachly-instance-id'];
2802
+ if (typeof hdrInst === 'string') instId = hdrInst.trim();
2803
+ // Smithery passes user config as a base64url-encoded JSON blob in ?config=
2804
+ const cfgParam = url.searchParams.get('config');
2805
+ if (cfgParam && (!jwtVal || !instId)) {
2806
+ try {
2807
+ const b64 = cfgParam.replace(/-/g, '+').replace(/_/g, '/');
2808
+ const padded = b64 + '='.repeat((4 - b64.length % 4) % 4);
2809
+ const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
2810
+ if (!jwtVal && typeof json.CACHLY_JWT === 'string') jwtVal = json.CACHLY_JWT;
2811
+ if (!instId && typeof json.CACHLY_BRAIN_INSTANCE_ID === 'string') instId = json.CACHLY_BRAIN_INSTANCE_ID;
2812
+ } catch { /* malformed config โ€” ignore, fall back to globals */ }
2813
+ }
2814
+ return { jwt: jwtVal, instanceId: instId };
2815
+ }
2816
+
2736
2817
  const httpServer = createServer((req, res) => {
2737
2818
  const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
2738
2819
  if (url.pathname === '/health') {
@@ -2740,6 +2821,8 @@ if (httpPort) {
2740
2821
  res.end(JSON.stringify({
2741
2822
  status: 'ok',
2742
2823
  version: CURRENT_VERSION,
2824
+ transport: 'streamable-http',
2825
+ multiTenant: true,
2743
2826
  zeroResults: {
2744
2827
  total: zeroResultsTotal,
2745
2828
  last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
@@ -2748,7 +2831,16 @@ if (httpPort) {
2748
2831
  return;
2749
2832
  }
2750
2833
  if (url.pathname === '/mcp' || url.pathname === '/') {
2751
- transport.handleRequest(req, res);
2834
+ const ctx = extractReqCtx(req, url);
2835
+ reqCtx.run(ctx, () => {
2836
+ transport.handleRequest(req, res).catch((err) => {
2837
+ process.stderr.write(`cachly-mcp HTTP handler error: ${(err as Error).message}\n`);
2838
+ if (!res.headersSent) {
2839
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2840
+ res.end(JSON.stringify({ error: 'internal_error' }));
2841
+ }
2842
+ });
2843
+ });
2752
2844
  return;
2753
2845
  }
2754
2846
  res.writeHead(404);