@cachly-dev/mcp-server 0.10.48 → 0.10.55
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/.github/workflows/publish.yml +2 -2
- package/dist/handlers/brain.js +89 -4
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/team.js +3 -1
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +212 -66
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/handlers/brain.ts +89 -7
- package/src/handlers/team.ts +3 -1
- package/src/index.ts +197 -96
package/src/handlers/brain.ts
CHANGED
|
@@ -6,16 +6,57 @@ import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '..
|
|
|
6
6
|
import { safeJsonParse } from '../utils.js';
|
|
7
7
|
import type { CKGEdge, CKGNode } from '../ckg.js';
|
|
8
8
|
import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
|
|
9
|
-
|
|
9
|
+
indexVocab as _indexVocab } from '../search.js';
|
|
10
10
|
import type { KeywordMatch } from '../search.js';
|
|
11
|
-
import { computeEmbedding, hasEmbedProvider
|
|
12
|
-
import { detectNamespace } from '../namespace.js';
|
|
11
|
+
import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
|
|
13
12
|
|
|
14
13
|
// ── Changelog (shown once per version in session_start) ──────────────────────
|
|
15
|
-
const MCP_VERSION = '0.10.
|
|
14
|
+
const MCP_VERSION = '0.10.55';
|
|
16
15
|
const WHATS_NEW: Record<string, string[]> = {
|
|
16
|
+
'0.10.55': [
|
|
17
|
+
`🆕 **What's new in cachly MCP v0.10.55:**`,
|
|
18
|
+
` ⚡ UX: API failure now shows a clear error instead of silently blocking for 30s`,
|
|
19
|
+
` ⚡ UX: provisioning wait reduced from 25s → 8s (Brain is usually ready sooner)`,
|
|
20
|
+
` 🔧 VS Code: setup spinner is now cancellable — dismiss and MCP auto-connects later`,
|
|
21
|
+
` 🧠 VS Code: Brain Health panel shows offline-queued lessons with pending sync count`,
|
|
22
|
+
],
|
|
23
|
+
'0.10.54': [
|
|
24
|
+
`🆕 **What's new in cachly MCP v${MCP_VERSION}:**`,
|
|
25
|
+
` 👀 Perf: first tool call no longer blocked by auto-session (was freezing for 30-120s on large repos)`,
|
|
26
|
+
` 👀 Perf: git history bootstrap runs in background — session_start responds instantly`,
|
|
27
|
+
` 🐞 Fixed: VS Code lessons now include author attribution (broken since launch)`,
|
|
28
|
+
],
|
|
29
|
+
'0.10.53': [
|
|
30
|
+
`🆕 **What's new in cachly MCP v${MCP_VERSION}:**`,
|
|
31
|
+
` 🐛 Fixed: Brain bootstrap summary now visible in session_start (was silently discarded)`,
|
|
32
|
+
` 🐛 Fixed: team lessons now indexed correctly (\`ts\` field was stored as \`timestamp\`)`,
|
|
33
|
+
` ✨ New: proactive follow-up hints after \`learn_from_attempts\` (CKG co-occurrence patterns)`,
|
|
34
|
+
` ✨ New: author name attribution for VS Code lessons (set \`cachly.authorName\` in VS Code settings)`,
|
|
35
|
+
],
|
|
36
|
+
'0.10.52': [
|
|
37
|
+
`🆕 **What's new in cachly MCP v0.10.52:**`,
|
|
38
|
+
` 🚀 Zero-click setup: Brain activates automatically on first call (no auth prompt needed)`,
|
|
39
|
+
` 🐛 Fixed: all Brain connections now use decrypted passwords (resolves AUTH errors for all users)`,
|
|
40
|
+
],
|
|
41
|
+
'0.10.50': [
|
|
42
|
+
`🆕 **What's new in cachly MCP v0.10.50:**`,
|
|
43
|
+
` 🐛 Fixed: Brain connection now works out of the box (was failing with AUTH error)`,
|
|
44
|
+
` 🐛 Fixed: TLS defaults to off for free-tier instances (no more TLS handshake failure)`,
|
|
45
|
+
` ✅ \`get_api_status\` correctly shows API key type instead of "invalid JWT format"`,
|
|
46
|
+
],
|
|
47
|
+
'0.10.46': [
|
|
48
|
+
`🆕 **What's new in v0.10.46:**`,
|
|
49
|
+
` ✅ \`brain_from_git\` — auto-learns from your full git history on setup`,
|
|
50
|
+
` ✅ \`smart_recall\` — semantic + keyword hybrid search, 3× more relevant results`,
|
|
51
|
+
` ✅ VS Code Extension — Brain persists across all projects, zero config`,
|
|
52
|
+
` ✅ Device-flow auth — zero-config login, no JWT needed in editor config`,
|
|
53
|
+
` ✅ Auto-provision — Brain instance created automatically after first login`,
|
|
54
|
+
` ✅ JWT now persisted to ~/.claude/mcp.json — survives editor restarts`,
|
|
55
|
+
` ✅ Confidence scoring — every recalled fact shows staleness + reliability`,
|
|
56
|
+
` ✅ CKG (Causal Knowledge Graph) — root-cause chains between bugs + fixes`,
|
|
57
|
+
],
|
|
17
58
|
'0.10.0': [
|
|
18
|
-
`🆕 **What's new in
|
|
59
|
+
`🆕 **What's new in v0.10.0:**`,
|
|
19
60
|
` ✅ Zero-friction setup — auto-provisions your Brain, no manual config needed`,
|
|
20
61
|
` ✅ 10× faster briefings — batch Redis fetches replace per-key loops`,
|
|
21
62
|
` ✅ Crash-proof data reads — corrupted cache entries no longer break tools`,
|
|
@@ -183,7 +224,7 @@ export async function handleBrainTool(
|
|
|
183
224
|
const existing = await redis.get(depKey);
|
|
184
225
|
const depTopics: string[] = safeJsonParse<string[]>(existing, []);
|
|
185
226
|
if (!depTopics.includes(topic)) depTopics.push(topic);
|
|
186
|
-
await redis.set(depKey, JSON.stringify(depTopics));
|
|
227
|
+
await redis.set(depKey, JSON.stringify(depTopics), 'EX', 365 * 86400); // 1-year TTL
|
|
187
228
|
}
|
|
188
229
|
|
|
189
230
|
const lessonObj = {
|
|
@@ -207,9 +248,11 @@ export async function handleBrainTool(
|
|
|
207
248
|
};
|
|
208
249
|
const lesson = JSON.stringify(lessonObj);
|
|
209
250
|
|
|
210
|
-
// Always append to the history list (audit log)
|
|
251
|
+
// Always append to the history list (audit log); cap at 200 entries to prevent unbounded growth
|
|
211
252
|
const listKey = `cachly:lessons:${topic}`;
|
|
212
253
|
await redis.rpush(listKey, lesson);
|
|
254
|
+
await redis.ltrim(listKey, -200, -1);
|
|
255
|
+
await redis.expire(listKey, 365 * 86400); // 1-year TTL
|
|
213
256
|
|
|
214
257
|
// Update best key for success/partial; for failure only update if no success exists
|
|
215
258
|
if (outcome === 'success' || outcome === 'partial') {
|
|
@@ -342,6 +385,44 @@ export async function handleBrainTool(
|
|
|
342
385
|
}
|
|
343
386
|
} catch { /* CKG updates are non-critical */ }
|
|
344
387
|
|
|
388
|
+
// ── Proactive follow-up suggestion (CKG co-occurrence patterns) ─────────
|
|
389
|
+
// After a successful fix, check if related concepts commonly follow it.
|
|
390
|
+
// Uses CKG co-occurs edges with confidence > 0.4 and at least 2 trials.
|
|
391
|
+
const proactiveSuggestions: string[] = [];
|
|
392
|
+
if (outcome === 'success' || outcome === 'partial') {
|
|
393
|
+
try {
|
|
394
|
+
const conceptId = ckgSlug(topic);
|
|
395
|
+
const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
|
|
396
|
+
for (const ek of fromEdgeKeys.slice(0, 20)) {
|
|
397
|
+
const er = await redis.get(ek);
|
|
398
|
+
if (!er) continue;
|
|
399
|
+
const edge = safeJsonParse<CKGEdge | null>(er, null);
|
|
400
|
+
if (!edge || edge.confidence < 0.4 || (edge.trials ?? 0) < 2) continue;
|
|
401
|
+
if (edge.edgeType !== 'co-occurs' && edge.edgeType !== 'causes') continue;
|
|
402
|
+
// Resolve related concept to a lesson key
|
|
403
|
+
const relTopic = edge.to
|
|
404
|
+
.replace(/^cause:/, '')
|
|
405
|
+
.replace(/^tag:/, '')
|
|
406
|
+
.replace(/^problem:/, '')
|
|
407
|
+
.replace(/-/g, ':')
|
|
408
|
+
.replace(/_/g, '-');
|
|
409
|
+
const relRaw = await redis.get(`cachly:lesson:best:${relTopic}`);
|
|
410
|
+
if (!relRaw) continue;
|
|
411
|
+
const relLesson = safeJsonParse<{ topic?: string; what_worked?: string } | null>(relRaw, null);
|
|
412
|
+
const displayTopic = relLesson?.topic ?? relTopic;
|
|
413
|
+
if (!relLesson || !relLesson.what_worked || displayTopic === topic) continue;
|
|
414
|
+
const pct = Math.round(edge.confidence * 100);
|
|
415
|
+
if (proactiveSuggestions.length === 0) proactiveSuggestions.push('');
|
|
416
|
+
proactiveSuggestions.push(
|
|
417
|
+
`🔮 **Pattern detected:** \`${displayTopic}\` tends to follow this (${pct}% co-occurrence across ${edge.trials} sessions)`,
|
|
418
|
+
` → ${relLesson.what_worked.slice(0, 100)}${relLesson.what_worked.length > 100 ? '…' : ''}`,
|
|
419
|
+
` → Pre-empt it now: \`recall_best_solution(topic="${displayTopic}")\``,
|
|
420
|
+
);
|
|
421
|
+
break; // one proactive hint is enough
|
|
422
|
+
}
|
|
423
|
+
} catch { /* non-critical */ }
|
|
424
|
+
}
|
|
425
|
+
|
|
345
426
|
const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
|
|
346
427
|
const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
|
|
347
428
|
const action = isUpdate ? 'updated' : 'stored';
|
|
@@ -364,6 +445,7 @@ export async function handleBrainTool(
|
|
|
364
445
|
: '',
|
|
365
446
|
...templateWarnings,
|
|
366
447
|
...iWasWrongWarning,
|
|
448
|
+
...proactiveSuggestions,
|
|
367
449
|
].filter(l => l !== '').join('\n');
|
|
368
450
|
}
|
|
369
451
|
|
package/src/handlers/team.ts
CHANGED
|
@@ -109,7 +109,9 @@ export async function handleTeamTool(
|
|
|
109
109
|
file_paths: file_paths ?? [],
|
|
110
110
|
commands: commands ?? [],
|
|
111
111
|
tags: [...(tags ?? []), 'team'],
|
|
112
|
-
|
|
112
|
+
ts: new Date().toISOString(),
|
|
113
|
+
verified_at: outcome === 'success' ? new Date().toISOString() : undefined,
|
|
114
|
+
confidence: 1.0,
|
|
113
115
|
recall_count: 0,
|
|
114
116
|
version: 2,
|
|
115
117
|
};
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import { execSync } from 'node:child_process';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
4
|
import { fileURLToPath } from 'node:url';
|
|
7
5
|
|
|
8
6
|
// True only when this file is the entry point (not imported by tests or other modules).
|
|
@@ -60,8 +58,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
60
58
|
|
|
61
59
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
62
60
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
63
|
-
const
|
|
64
|
-
const CURRENT_VERSION = '0.10.47';
|
|
61
|
+
const CURRENT_VERSION = '0.10.55';
|
|
65
62
|
|
|
66
63
|
// ── Per-request context (multi-tenant HTTP / Smithery) ───────────────────────
|
|
67
64
|
// In stdio mode the module-level JWT/instance globals apply.
|
|
@@ -86,8 +83,18 @@ async function resolveDefaultInstanceId(): Promise<string> {
|
|
|
86
83
|
if (_defaultInstanceId) return _defaultInstanceId;
|
|
87
84
|
if (!getJWT()) return '';
|
|
88
85
|
// Cooldown: don't hammer the API on every tool call after a transient failure.
|
|
86
|
+
// 10 s is enough to avoid rate-limit spam while not blocking the user for too long.
|
|
89
87
|
const now = Date.now();
|
|
90
|
-
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt <
|
|
88
|
+
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 10_000) {
|
|
89
|
+
throw new McpError(
|
|
90
|
+
ErrorCode.InvalidRequest,
|
|
91
|
+
'⚠️ Cachly Brain is temporarily unreachable (API error a few seconds ago).\n\n' +
|
|
92
|
+
'The server will retry automatically in a moment. If this persists:\n' +
|
|
93
|
+
'• Run `get_api_status` for a full diagnostic\n' +
|
|
94
|
+
'• Check that CACHLY_JWT is set and valid\n' +
|
|
95
|
+
'• Visit https://cachly.dev/instances to verify your Brain is running'
|
|
96
|
+
);
|
|
97
|
+
};
|
|
91
98
|
_defaultInstanceLastAttempt = now;
|
|
92
99
|
try {
|
|
93
100
|
const res = await fetch(`${API_URL}/api/v1/instances`, {
|
|
@@ -178,6 +185,26 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
178
185
|
} catch { /* use raw JWT as fallback */ }
|
|
179
186
|
JWT = apiKey;
|
|
180
187
|
setEmbedJwt(apiKey); // keep embeddings.ts in sync
|
|
188
|
+
// Persist API key to ~/.claude/mcp.json so the next editor restart
|
|
189
|
+
// doesn't start over from a missing CACHLY_JWT (fixes BrainRecallCount=0 bug).
|
|
190
|
+
try {
|
|
191
|
+
const { resolve: _res, dirname: _dir } = await import('node:path');
|
|
192
|
+
const { homedir: _hd } = await import('node:os');
|
|
193
|
+
const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
|
|
194
|
+
const { existsSync: _ex } = await import('node:fs');
|
|
195
|
+
const globalJwtPath = _res(_hd(), '.claude', 'mcp.json');
|
|
196
|
+
await _mk(_dir(globalJwtPath), { recursive: true });
|
|
197
|
+
let cfg: { mcpServers?: Record<string, { env?: Record<string, string> }> } = {};
|
|
198
|
+
if (_ex(globalJwtPath)) {
|
|
199
|
+
try { cfg = JSON.parse(await _rf(globalJwtPath, 'utf-8')) as typeof cfg; } catch { /* corrupt — start fresh */ }
|
|
200
|
+
}
|
|
201
|
+
cfg.mcpServers ??= {};
|
|
202
|
+
cfg.mcpServers['cachly'] ??= {};
|
|
203
|
+
cfg.mcpServers['cachly'].env ??= {};
|
|
204
|
+
cfg.mcpServers['cachly'].env['CACHLY_JWT'] = apiKey;
|
|
205
|
+
if (_defaultInstanceId) cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
|
|
206
|
+
await _wf(globalJwtPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
207
|
+
} catch { /* non-fatal — editor will just prompt again on next restart */ }
|
|
181
208
|
_deviceFlow = null;
|
|
182
209
|
sendFunnelEvent('device_flow_completed');
|
|
183
210
|
// Auto-provision: find or create the user's brain instance.
|
|
@@ -243,22 +270,61 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
243
270
|
return 'pending';
|
|
244
271
|
} catch { return 'pending'; }
|
|
245
272
|
}
|
|
246
|
-
// ──
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
273
|
+
// ── Silent Auto-Setup (instant-trial, zero-click) ─────────────────────────
|
|
274
|
+
// Attempts to provision a free trial silently on the first tool call when no
|
|
275
|
+
// JWT is set. Returns true if credentials were written and the tool can
|
|
276
|
+
// proceed transparently; false if the API is unreachable or trials are full.
|
|
277
|
+
// Only runs once per process (subsequent failures short-circuit immediately).
|
|
278
|
+
let _silentSetupAttempted = false;
|
|
279
|
+
async function silentAutoSetup(): Promise<boolean> {
|
|
280
|
+
if (_silentSetupAttempted) return false;
|
|
281
|
+
_silentSetupAttempted = true;
|
|
282
|
+
try {
|
|
283
|
+
type TrialResp = { api_key: string; expires_at?: string; instance_id?: string; trial?: boolean };
|
|
284
|
+
const res = await fetch(`${API_URL}/auth/instant-trial`, {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'Content-Type': 'application/json' },
|
|
287
|
+
body: JSON.stringify({}),
|
|
288
|
+
signal: AbortSignal.timeout(8000),
|
|
289
|
+
});
|
|
290
|
+
if (!res.ok) return false;
|
|
291
|
+
const data = await res.json() as TrialResp;
|
|
292
|
+
if (!data.api_key) return false;
|
|
251
293
|
|
|
294
|
+
JWT = data.api_key;
|
|
295
|
+
setEmbedJwt(data.api_key);
|
|
296
|
+
if (data.instance_id) _defaultInstanceId = data.instance_id;
|
|
297
|
+
sendFunnelEvent('trial_provisioned', { version: CURRENT_VERSION });
|
|
252
298
|
|
|
299
|
+
// Persist credentials so the next editor restart doesn't re-provision.
|
|
300
|
+
try {
|
|
301
|
+
const { resolve: _rp, dirname: _dn } = await import('node:path');
|
|
302
|
+
const { homedir: _hd } = await import('node:os');
|
|
303
|
+
const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
|
|
304
|
+
const { existsSync: _ex } = await import('node:fs');
|
|
305
|
+
const cfgPath = _rp(_hd(), '.claude', 'mcp.json');
|
|
306
|
+
await _mk(_dn(cfgPath), { recursive: true });
|
|
307
|
+
let cfg: { mcpServers?: Record<string, { env?: Record<string, string> }> } = {};
|
|
308
|
+
if (_ex(cfgPath)) {
|
|
309
|
+
try { cfg = JSON.parse(await _rf(cfgPath, 'utf-8')) as typeof cfg; } catch { /* corrupt — overwrite */ }
|
|
310
|
+
}
|
|
311
|
+
cfg.mcpServers ??= {};
|
|
312
|
+
cfg.mcpServers['cachly'] ??= {};
|
|
313
|
+
cfg.mcpServers['cachly'].env ??= {};
|
|
314
|
+
cfg.mcpServers['cachly'].env['CACHLY_JWT'] = data.api_key;
|
|
315
|
+
if (_defaultInstanceId) cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
|
|
316
|
+
await _wf(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
317
|
+
} catch { /* non-fatal — trial is active for this session even if persist fails */ }
|
|
318
|
+
return true;
|
|
319
|
+
} catch { return false; }
|
|
320
|
+
}
|
|
253
321
|
// ── Embeddings (imported from embeddings.ts) ────────────────────────────────
|
|
254
|
-
import {
|
|
255
|
-
hasEmbedProvider, embedProviderHint } from './embeddings.js';
|
|
322
|
+
import { setEmbedJwt } from './embeddings.js';
|
|
256
323
|
|
|
257
324
|
// ── Search Engine (imported from search.ts) ─────────────────────────────────
|
|
258
325
|
import { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
|
|
259
326
|
katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP,
|
|
260
|
-
|
|
261
|
-
import type { KeywordMatch } from './search.js';
|
|
327
|
+
ZERO_RESULTS_LOG, zeroResultsTotal } from './search.js';
|
|
262
328
|
|
|
263
329
|
// ── Exported for testing (re-export from search.ts) ─────────────────────────
|
|
264
330
|
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
|
|
@@ -267,19 +333,6 @@ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp,
|
|
|
267
333
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
268
334
|
// Instance is imported from handlers/brain.ts
|
|
269
335
|
|
|
270
|
-
interface CreateResponse {
|
|
271
|
-
instance_id: string;
|
|
272
|
-
checkout_url?: string;
|
|
273
|
-
status: string;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
interface SemanticSearchResponse {
|
|
277
|
-
found: boolean;
|
|
278
|
-
id?: string;
|
|
279
|
-
similarity?: number;
|
|
280
|
-
prompt?: string;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
336
|
// ── Connection pool ───────────────────────────────────────────────────────────
|
|
284
337
|
|
|
285
338
|
/** Reuse Redis connections across tool calls (keyed by instance_id). */
|
|
@@ -301,7 +354,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
301
354
|
// Verify the pooled connection is still alive before reusing it.
|
|
302
355
|
// ioredis 'error' eviction is async — a connection can be in the pool
|
|
303
356
|
// but already internally broken (status 'reconnecting' or 'end').
|
|
304
|
-
const status = (cached as
|
|
357
|
+
const status = (cached as { status?: string }).status;
|
|
305
358
|
if (status === 'ready' || status === 'connect') {
|
|
306
359
|
return cached;
|
|
307
360
|
}
|
|
@@ -310,14 +363,13 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
310
363
|
cached.disconnect();
|
|
311
364
|
}
|
|
312
365
|
|
|
313
|
-
// Fetch instance, waiting up to
|
|
314
|
-
//
|
|
315
|
-
// all happen in quick succession and the instance isn't running yet.
|
|
366
|
+
// Fetch instance, waiting up to 8 s if it is still provisioning.
|
|
367
|
+
// (autoStartSession already gave the instance a head-start; 8 s covers any remaining gap.)
|
|
316
368
|
let inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
317
369
|
if (inst.status === 'provisioning') {
|
|
318
|
-
const deadline = Date.now() +
|
|
370
|
+
const deadline = Date.now() + 8_000;
|
|
319
371
|
while (inst.status === 'provisioning' && Date.now() < deadline) {
|
|
320
|
-
await new Promise(r => setTimeout(r,
|
|
372
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
321
373
|
inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
322
374
|
}
|
|
323
375
|
}
|
|
@@ -357,7 +409,9 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
357
409
|
|
|
358
410
|
// Fetch connection details (includes password) from the dedicated endpoint.
|
|
359
411
|
let password: string | undefined;
|
|
360
|
-
|
|
412
|
+
// Default to false — most free/dev instances run without TLS.
|
|
413
|
+
// Only enable TLS when the API explicitly returns tls_enabled: true.
|
|
414
|
+
let tlsEnabled = inst.tls_enabled === true;
|
|
361
415
|
try {
|
|
362
416
|
const conn = await apiFetch<{ password?: string; tls_enabled?: boolean }>(
|
|
363
417
|
`/api/v1/instances/${instance_id}/connection`
|
|
@@ -383,6 +437,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
383
437
|
lazyConnect: true,
|
|
384
438
|
enableReadyCheck: true,
|
|
385
439
|
connectTimeout: 8000,
|
|
440
|
+
commandTimeout: 10000,
|
|
386
441
|
retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
|
|
387
442
|
});
|
|
388
443
|
|
|
@@ -407,6 +462,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
407
462
|
lazyConnect: true,
|
|
408
463
|
enableReadyCheck: true,
|
|
409
464
|
connectTimeout: 8000,
|
|
465
|
+
commandTimeout: 10000,
|
|
410
466
|
retryStrategy: () => null,
|
|
411
467
|
});
|
|
412
468
|
retryClient.on('error', () => pool.delete(instance_id));
|
|
@@ -468,11 +524,6 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|
|
468
524
|
return res.json() as Promise<T>;
|
|
469
525
|
}
|
|
470
526
|
|
|
471
|
-
import { detectNamespace } from './namespace.js';
|
|
472
|
-
|
|
473
|
-
// ── CKG (imported from ckg.ts) ─────────────────────────────────────────────
|
|
474
|
-
import type { CKGEdge, CKGNode } from './ckg.js';
|
|
475
|
-
import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from './ckg.js';
|
|
476
527
|
import { handleBrainTool } from './handlers/brain.js';
|
|
477
528
|
import { handleContextTool } from './handlers/context.js';
|
|
478
529
|
import { handleInstanceTool } from './handlers/instances.js';
|
|
@@ -510,7 +561,10 @@ function sendFunnelEvent(event: string, extra?: Record<string, unknown>): void {
|
|
|
510
561
|
headers: { 'Content-Type': 'application/json' },
|
|
511
562
|
body: JSON.stringify({
|
|
512
563
|
event, version: CURRENT_VERSION, editor: detectEditor(),
|
|
513
|
-
|
|
564
|
+
// Send Cachly API keys (cky_*) for server-side tenant resolution (recall counting, emails).
|
|
565
|
+
// Raw Keycloak JWTs (eyJ...) are intentionally excluded — they are short-lived bearer tokens
|
|
566
|
+
// that should never leave the MCP process boundary.
|
|
567
|
+
...(getJWT().startsWith('cky_') ? { api_key: getJWT() } : {}),
|
|
514
568
|
...extra,
|
|
515
569
|
}),
|
|
516
570
|
signal: AbortSignal.timeout(3000),
|
|
@@ -532,12 +586,20 @@ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
|
|
|
532
586
|
}
|
|
533
587
|
|
|
534
588
|
async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
535
|
-
// Guard: if no JWT,
|
|
589
|
+
// Guard: if no JWT, try silent auto-setup first, then fall back to device flow.
|
|
536
590
|
if (!getJWT()) {
|
|
591
|
+
// ── Step 1: Silent instant-trial (zero-click) ────────────────────────
|
|
592
|
+
// On the very first tool call, attempt to silently provision a free trial.
|
|
593
|
+
// If it succeeds the user never sees an auth prompt — Brain is just active.
|
|
594
|
+
const provisioned = await silentAutoSetup();
|
|
595
|
+
if (provisioned) {
|
|
596
|
+
return handleTool(name, args); // Transparent re-entry with active credentials
|
|
597
|
+
}
|
|
598
|
+
|
|
537
599
|
void sendAnonymousTelemetry(name);
|
|
538
600
|
|
|
539
|
-
// ── Zero-credential device flow
|
|
540
|
-
// 1st call: start device flow, return code + URL
|
|
601
|
+
// ── Step 2: Zero-credential device flow ─────────────────────────────
|
|
602
|
+
// 1st call after silent setup fails: start device flow, return code + URL
|
|
541
603
|
// 2nd+ calls: poll for token; once authenticated, proceed transparently
|
|
542
604
|
if (_deviceFlow) {
|
|
543
605
|
const result = await pollDeviceFlow(_deviceFlow);
|
|
@@ -611,7 +673,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
611
673
|
}
|
|
612
674
|
|
|
613
675
|
// Delegate brain tools (learn, recall, session, etc.)
|
|
614
|
-
|
|
676
|
+
let brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
|
|
615
677
|
if (brainResult !== null) {
|
|
616
678
|
if (!_firstCallSuccessSent && getJWT()) {
|
|
617
679
|
_firstCallSuccessSent = true;
|
|
@@ -636,28 +698,40 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
636
698
|
if (!isFirstSession && resultText.length > 100) {
|
|
637
699
|
// Existing brain — session_start acts as implicit recall → increment counter.
|
|
638
700
|
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Auto-bootstrap brain_from_git when workspace_path is provided and it hasn't run yet.
|
|
704
|
+
// We check a sentinel key in Valkey so this only fires once per workspace path,
|
|
705
|
+
// not just on the very first session (handles cases where brain was empty but
|
|
706
|
+
// session data existed, or where the user re-installed).
|
|
707
|
+
if (args.workspace_path) {
|
|
642
708
|
try {
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
//
|
|
652
|
-
const
|
|
653
|
-
? (gitBootstrap as { content: { text?: string }[] }).content.map((c) => c.text ?? '').join('\n')
|
|
654
|
-
: String(gitBootstrap);
|
|
709
|
+
const workspacePath = args.workspace_path as string;
|
|
710
|
+
const autoKey = `cachly:brain_from_git:auto_done:${Buffer.from(workspacePath).toString('base64').slice(0, 40)}`;
|
|
711
|
+
const redis = await getConnection(instanceId);
|
|
712
|
+
const alreadyDone = await redis.get(autoKey);
|
|
713
|
+
if (!alreadyDone) {
|
|
714
|
+
// Mark immediately to prevent concurrent runs across multiple MCP tool calls
|
|
715
|
+
await redis.set(autoKey, '1', 'EX', 365 * 86400);
|
|
716
|
+
// Append a placeholder so the user gets immediate feedback, then bootstrap in background.
|
|
717
|
+
// Running brain_from_git synchronously would block session_start for 30-120s on large repos.
|
|
718
|
+
const bgMsg = '\n\n---\n\n🚀 **Auto-indexing your git history in the background** — your Brain will learn from recent commits automatically. No action needed.';
|
|
655
719
|
if (typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult) {
|
|
656
|
-
(brainResult as { content: { type: string; text: string }[] }).content.push({
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
});
|
|
720
|
+
(brainResult as { content: { type: string; text: string }[] }).content.push({ type: 'text', text: bgMsg });
|
|
721
|
+
} else if (typeof brainResult === 'string') {
|
|
722
|
+
brainResult = brainResult + bgMsg;
|
|
660
723
|
}
|
|
724
|
+
// Fire-and-forget — never block the session_start response
|
|
725
|
+
void handleFedbrainTool('brain_from_git', {
|
|
726
|
+
instance_id: instanceId,
|
|
727
|
+
repo_path: workspacePath,
|
|
728
|
+
limit: 50,
|
|
729
|
+
}, getConnection, apiFetch).then((gitBootstrap) => {
|
|
730
|
+
if (gitBootstrap !== null) {
|
|
731
|
+
sendFunnelEvent('brain_from_git', telemetryExtra);
|
|
732
|
+
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
733
|
+
}
|
|
734
|
+
}).catch(() => { /* non-critical */ });
|
|
661
735
|
}
|
|
662
736
|
} catch { /* git bootstrap errors must never break session_start */ }
|
|
663
737
|
}
|
|
@@ -760,28 +834,38 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
760
834
|
}
|
|
761
835
|
|
|
762
836
|
// Decode JWT claims (inspection only, no verification)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
837
|
+
const jwt = getJWT();
|
|
838
|
+
let authInfo: string;
|
|
839
|
+
// API keys (cky_live_... / cky_trial_...) are valid long-lived credentials.
|
|
840
|
+
// Only try to decode as JWT (3-part base64url) if it looks like one.
|
|
841
|
+
if (jwt.startsWith('cky_')) {
|
|
842
|
+
const keyType = jwt.startsWith('cky_trial_') ? 'Trial' : 'Live';
|
|
843
|
+
const keyId = jwt.slice(0, 20) + '…';
|
|
844
|
+
authInfo = `✅ API key (${keyType})\n Key prefix: ${keyId}`;
|
|
845
|
+
} else {
|
|
846
|
+
authInfo = '❌ invalid credential format';
|
|
847
|
+
try {
|
|
848
|
+
const parts = jwt.split('.');
|
|
849
|
+
if (parts.length === 3) {
|
|
850
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
|
|
851
|
+
sub?: string; exp?: number; iss?: string;
|
|
852
|
+
};
|
|
853
|
+
const sub = payload.sub ?? '(unknown)';
|
|
854
|
+
const iss = payload.iss ?? '(unknown)';
|
|
855
|
+
const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
|
|
856
|
+
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
857
|
+
const expired = expTs ? expTs < new Date() : false;
|
|
858
|
+
authInfo = [
|
|
859
|
+
`✅ JWT decoded`,
|
|
860
|
+
` Sub (user ID): ${sub}`,
|
|
861
|
+
` Provider: ${provider}`,
|
|
862
|
+
` Issuer: ${iss}`,
|
|
863
|
+
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
|
|
864
|
+
].join('\n');
|
|
865
|
+
}
|
|
866
|
+
} catch {
|
|
867
|
+
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
782
868
|
}
|
|
783
|
-
} catch {
|
|
784
|
-
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
785
869
|
}
|
|
786
870
|
|
|
787
871
|
// List instances + connectivity
|
|
@@ -923,12 +1007,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
923
1007
|
|| _defaultInstanceId
|
|
924
1008
|
|| process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
925
1009
|
if (instanceId) {
|
|
926
|
-
|
|
1010
|
+
// Fire-and-forget — session management is non-critical and must never block tool calls.
|
|
1011
|
+
// The session_start briefing (which includes brain_from_git) can take 30-120s on large
|
|
1012
|
+
// repos; awaiting it here would freeze the user's first MCP tool call entirely.
|
|
1013
|
+
void autoStartSession(instanceId);
|
|
927
1014
|
}
|
|
928
|
-
} else if (_autoSessionStarting) {
|
|
929
|
-
// Another concurrent call is mid-start — wait for it before proceeding.
|
|
930
|
-
await _autoSessionStarting.catch(() => undefined);
|
|
931
1015
|
}
|
|
1016
|
+
// Note: we intentionally do NOT await _autoSessionStarting for any call.
|
|
1017
|
+
// The session tracker runs in background — tool calls must always proceed immediately.
|
|
932
1018
|
if (!sessionTools.has(name)) _autoSessionToolCount++;
|
|
933
1019
|
|
|
934
1020
|
try {
|
|
@@ -1998,7 +2084,7 @@ if (process.argv[2] === 'status') {
|
|
|
1998
2084
|
if (process.argv[2] === 'health') {
|
|
1999
2085
|
const { existsSync } = await import('node:fs');
|
|
2000
2086
|
const { readFile } = await import('node:fs/promises');
|
|
2001
|
-
const { resolve
|
|
2087
|
+
const { resolve } = await import('node:path');
|
|
2002
2088
|
|
|
2003
2089
|
const cwd = process.cwd();
|
|
2004
2090
|
let passed = 0;
|
|
@@ -2015,6 +2101,9 @@ if (process.argv[2] === 'health') {
|
|
|
2015
2101
|
const jwt = process.env.CACHLY_JWT ?? '';
|
|
2016
2102
|
if (!jwt) {
|
|
2017
2103
|
fail('CACHLY_JWT not set — run: npx @cachly-dev/mcp-server@latest setup');
|
|
2104
|
+
} else if (jwt.startsWith('cky_')) {
|
|
2105
|
+
// Opaque cachly API key — no JWT expiry to check, format is valid by prefix
|
|
2106
|
+
ok('API key set (cky_*** opaque key)');
|
|
2018
2107
|
} else {
|
|
2019
2108
|
try {
|
|
2020
2109
|
const parts = jwt.split('.');
|
|
@@ -2032,7 +2121,7 @@ if (process.argv[2] === 'health') {
|
|
|
2032
2121
|
ok(`JWT valid${minsLeft !== null ? ` (expires in ${Math.floor(minsLeft / 60)}h ${minsLeft % 60}m)` : ''}`);
|
|
2033
2122
|
}
|
|
2034
2123
|
} catch {
|
|
2035
|
-
fail('CACHLY_JWT format invalid (expected JWT with 3 parts)');
|
|
2124
|
+
fail('CACHLY_JWT format invalid (expected a cky_*** API key or a JWT with 3 parts)');
|
|
2036
2125
|
}
|
|
2037
2126
|
}
|
|
2038
2127
|
|
|
@@ -2080,11 +2169,23 @@ if (process.argv[2] === 'health') {
|
|
|
2080
2169
|
signal: AbortSignal.timeout(5000),
|
|
2081
2170
|
});
|
|
2082
2171
|
if (connRes.ok) {
|
|
2083
|
-
const conn = await connRes.json() as { password?: string; tls_enabled?: boolean };
|
|
2084
|
-
|
|
2085
|
-
|
|
2172
|
+
const conn = await connRes.json() as { password?: string; connection_string?: string; tls_enabled?: boolean };
|
|
2173
|
+
// password may be a direct field, or embedded in connection_string as redis://:pass@host:port
|
|
2174
|
+
if (conn.password) {
|
|
2175
|
+
pingPassword = conn.password;
|
|
2176
|
+
} else if (conn.connection_string) {
|
|
2177
|
+
try {
|
|
2178
|
+
const u = new URL(conn.connection_string);
|
|
2179
|
+
if (u.password) pingPassword = decodeURIComponent(u.password);
|
|
2180
|
+
} catch { /* leave pingPassword undefined */ }
|
|
2181
|
+
}
|
|
2182
|
+
pingTls = conn.tls_enabled === true;
|
|
2183
|
+
} else {
|
|
2184
|
+
warn(`Could not fetch Redis credentials (HTTP ${connRes.status}) — connectivity test may fail`);
|
|
2086
2185
|
}
|
|
2087
|
-
} catch {
|
|
2186
|
+
} catch (connErr) {
|
|
2187
|
+
warn(`Could not fetch Redis credentials — ${(connErr as Error).message}`);
|
|
2188
|
+
}
|
|
2088
2189
|
const pingClient = new Redis({
|
|
2089
2190
|
host: inst.host, port: inst.port,
|
|
2090
2191
|
password: pingPassword,
|
|
@@ -2299,7 +2400,7 @@ if (process.argv[2] === 'setup') {
|
|
|
2299
2400
|
if (!keyBody.key) throw new Error('no key in response');
|
|
2300
2401
|
token = keyBody.key; // swap JWT → cky_live_...
|
|
2301
2402
|
console.log(' ✓\n');
|
|
2302
|
-
} catch
|
|
2403
|
+
} catch {
|
|
2303
2404
|
console.log(' (skipped)\n');
|
|
2304
2405
|
// Non-fatal: fall back to using the Keycloak JWT directly.
|
|
2305
2406
|
// It will expire but setup still works for now.
|
|
@@ -2689,7 +2790,7 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2689
2790
|
limit: maxCommits,
|
|
2690
2791
|
});
|
|
2691
2792
|
console.log(result);
|
|
2692
|
-
sendFunnelEvent('brain_from_git', {
|
|
2793
|
+
sendFunnelEvent('brain_from_git', { instance_id: instanceId });
|
|
2693
2794
|
console.log('\n✅ Brain learned from your commits.\n');
|
|
2694
2795
|
// Let fire-and-forget telemetry flush before exit.
|
|
2695
2796
|
await new Promise(r => setTimeout(r, 300));
|