@cachly-dev/mcp-server 0.10.46 โ 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.
- package/dist/__tests__/brain-flow.test.js +0 -141
- package/dist/__tests__/brain-flow.test.js.map +1 -1
- package/dist/__tests__/brain-predict-failures.test.d.ts +17 -0
- package/dist/__tests__/brain-predict-failures.test.js +339 -0
- package/dist/__tests__/brain-predict-failures.test.js.map +1 -0
- package/dist/handlers/brain.js +21 -120
- package/dist/handlers/brain.js.map +1 -1
- package/dist/index.js +80 -34
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/smithery.yaml +10 -21
- package/src/__tests__/brain-flow.test.ts +0 -174
- package/src/__tests__/brain-predict-failures.test.ts +430 -0
- package/src/handlers/brain.ts +22 -124
- package/src/index.ts +131 -39
package/src/handlers/brain.ts
CHANGED
|
@@ -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
|
|
1060
|
-
|
|
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.
|
|
1135
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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.
|
|
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 (!
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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))
|
|
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', ...(
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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 =
|
|
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', ...(
|
|
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(
|
|
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 ${
|
|
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
|
-
...(
|
|
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 (!
|
|
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 &&
|
|
616
|
+
if (!_firstCallSuccessSent && getJWT()) {
|
|
561
617
|
_firstCallSuccessSent = true;
|
|
562
|
-
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ??
|
|
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) ??
|
|
567
|
-
const telemetryExtra =
|
|
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 &&
|
|
684
|
+
if (!_firstCallSuccessSent && getJWT()) {
|
|
629
685
|
_firstCallSuccessSent = true;
|
|
630
|
-
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ??
|
|
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) ??
|
|
657
|
-
const telemetryExtra =
|
|
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) ??
|
|
667
|
-
const telemetryExtra =
|
|
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 (!
|
|
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 =
|
|
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 ===
|
|
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 || !
|
|
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 || !
|
|
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
|
-
|
|
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
|
-
|
|
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);
|