@cachly-dev/mcp-server 0.10.47 → 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/README.md +33 -5
- package/dist/__tests__/brain-flow.test.js +2 -2
- package/dist/__tests__/brain-flow.test.js.map +1 -1
- package/dist/__tests__/handlers-unit.test.js +3 -1
- package/dist/__tests__/handlers-unit.test.js.map +1 -1
- package/dist/handlers/brain.js +90 -5
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +6 -5
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/roadmap.js +1 -1
- package/dist/handlers/roadmap.js.map +1 -1
- package/dist/handlers/team.js +4 -2
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +342 -83
- package/dist/index.js.map +1 -1
- package/dist/search.js +3 -3
- package/dist/search.js.map +1 -1
- package/package.json +2 -1
- package/smithery.yaml +0 -2
- package/src/__tests__/brain-flow.test.ts +2 -2
- package/src/__tests__/handlers-unit.test.ts +2 -1
- package/src/handlers/brain.ts +90 -8
- package/src/handlers/fedbrain.ts +6 -5
- package/src/handlers/roadmap.ts +1 -1
- package/src/handlers/team.ts +4 -2
- package/src/index.ts +279 -107
- package/src/search.ts +3 -3
package/dist/index.js
CHANGED
|
@@ -49,8 +49,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
49
49
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
50
50
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
51
51
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
52
|
-
const
|
|
53
|
-
const CURRENT_VERSION = '0.10.47';
|
|
52
|
+
const CURRENT_VERSION = '0.10.55';
|
|
54
53
|
const reqCtx = new AsyncLocalStorage();
|
|
55
54
|
function getJWT() { return reqCtx.getStore()?.jwt || JWT; }
|
|
56
55
|
function getDefaultInstanceId() { return reqCtx.getStore()?.instanceId || _defaultInstanceId; }
|
|
@@ -70,9 +69,16 @@ async function resolveDefaultInstanceId() {
|
|
|
70
69
|
if (!getJWT())
|
|
71
70
|
return '';
|
|
72
71
|
// Cooldown: don't hammer the API on every tool call after a transient failure.
|
|
72
|
+
// 10 s is enough to avoid rate-limit spam while not blocking the user for too long.
|
|
73
73
|
const now = Date.now();
|
|
74
|
-
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt <
|
|
75
|
-
|
|
74
|
+
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 10_000) {
|
|
75
|
+
throw new McpError(ErrorCode.InvalidRequest, '⚠️ Cachly Brain is temporarily unreachable (API error a few seconds ago).\n\n' +
|
|
76
|
+
'The server will retry automatically in a moment. If this persists:\n' +
|
|
77
|
+
'• Run `get_api_status` for a full diagnostic\n' +
|
|
78
|
+
'• Check that CACHLY_JWT is set and valid\n' +
|
|
79
|
+
'• Visit https://cachly.dev/instances to verify your Brain is running');
|
|
80
|
+
}
|
|
81
|
+
;
|
|
76
82
|
_defaultInstanceLastAttempt = now;
|
|
77
83
|
try {
|
|
78
84
|
const res = await fetch(`${API_URL}/api/v1/instances`, {
|
|
@@ -153,6 +159,31 @@ async function pollDeviceFlow(flow) {
|
|
|
153
159
|
catch { /* use raw JWT as fallback */ }
|
|
154
160
|
JWT = apiKey;
|
|
155
161
|
setEmbedJwt(apiKey); // keep embeddings.ts in sync
|
|
162
|
+
// Persist API key to ~/.claude/mcp.json so the next editor restart
|
|
163
|
+
// doesn't start over from a missing CACHLY_JWT (fixes BrainRecallCount=0 bug).
|
|
164
|
+
try {
|
|
165
|
+
const { resolve: _res, dirname: _dir } = await import('node:path');
|
|
166
|
+
const { homedir: _hd } = await import('node:os');
|
|
167
|
+
const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
|
|
168
|
+
const { existsSync: _ex } = await import('node:fs');
|
|
169
|
+
const globalJwtPath = _res(_hd(), '.claude', 'mcp.json');
|
|
170
|
+
await _mk(_dir(globalJwtPath), { recursive: true });
|
|
171
|
+
let cfg = {};
|
|
172
|
+
if (_ex(globalJwtPath)) {
|
|
173
|
+
try {
|
|
174
|
+
cfg = JSON.parse(await _rf(globalJwtPath, 'utf-8'));
|
|
175
|
+
}
|
|
176
|
+
catch { /* corrupt — start fresh */ }
|
|
177
|
+
}
|
|
178
|
+
cfg.mcpServers ??= {};
|
|
179
|
+
cfg.mcpServers['cachly'] ??= {};
|
|
180
|
+
cfg.mcpServers['cachly'].env ??= {};
|
|
181
|
+
cfg.mcpServers['cachly'].env['CACHLY_JWT'] = apiKey;
|
|
182
|
+
if (_defaultInstanceId)
|
|
183
|
+
cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
|
|
184
|
+
await _wf(globalJwtPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
185
|
+
}
|
|
186
|
+
catch { /* non-fatal — editor will just prompt again on next restart */ }
|
|
156
187
|
_deviceFlow = null;
|
|
157
188
|
sendFunnelEvent('device_flow_completed');
|
|
158
189
|
// Auto-provision: find or create the user's brain instance.
|
|
@@ -225,12 +256,71 @@ async function pollDeviceFlow(flow) {
|
|
|
225
256
|
return 'pending';
|
|
226
257
|
}
|
|
227
258
|
}
|
|
259
|
+
// ── Silent Auto-Setup (instant-trial, zero-click) ─────────────────────────
|
|
260
|
+
// Attempts to provision a free trial silently on the first tool call when no
|
|
261
|
+
// JWT is set. Returns true if credentials were written and the tool can
|
|
262
|
+
// proceed transparently; false if the API is unreachable or trials are full.
|
|
263
|
+
// Only runs once per process (subsequent failures short-circuit immediately).
|
|
264
|
+
let _silentSetupAttempted = false;
|
|
265
|
+
async function silentAutoSetup() {
|
|
266
|
+
if (_silentSetupAttempted)
|
|
267
|
+
return false;
|
|
268
|
+
_silentSetupAttempted = true;
|
|
269
|
+
try {
|
|
270
|
+
const res = await fetch(`${API_URL}/auth/instant-trial`, {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: { 'Content-Type': 'application/json' },
|
|
273
|
+
body: JSON.stringify({}),
|
|
274
|
+
signal: AbortSignal.timeout(8000),
|
|
275
|
+
});
|
|
276
|
+
if (!res.ok)
|
|
277
|
+
return false;
|
|
278
|
+
const data = await res.json();
|
|
279
|
+
if (!data.api_key)
|
|
280
|
+
return false;
|
|
281
|
+
JWT = data.api_key;
|
|
282
|
+
setEmbedJwt(data.api_key);
|
|
283
|
+
if (data.instance_id)
|
|
284
|
+
_defaultInstanceId = data.instance_id;
|
|
285
|
+
sendFunnelEvent('trial_provisioned', { version: CURRENT_VERSION });
|
|
286
|
+
// Persist credentials so the next editor restart doesn't re-provision.
|
|
287
|
+
try {
|
|
288
|
+
const { resolve: _rp, dirname: _dn } = await import('node:path');
|
|
289
|
+
const { homedir: _hd } = await import('node:os');
|
|
290
|
+
const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
|
|
291
|
+
const { existsSync: _ex } = await import('node:fs');
|
|
292
|
+
const cfgPath = _rp(_hd(), '.claude', 'mcp.json');
|
|
293
|
+
await _mk(_dn(cfgPath), { recursive: true });
|
|
294
|
+
let cfg = {};
|
|
295
|
+
if (_ex(cfgPath)) {
|
|
296
|
+
try {
|
|
297
|
+
cfg = JSON.parse(await _rf(cfgPath, 'utf-8'));
|
|
298
|
+
}
|
|
299
|
+
catch { /* corrupt — overwrite */ }
|
|
300
|
+
}
|
|
301
|
+
cfg.mcpServers ??= {};
|
|
302
|
+
cfg.mcpServers['cachly'] ??= {};
|
|
303
|
+
cfg.mcpServers['cachly'].env ??= {};
|
|
304
|
+
cfg.mcpServers['cachly'].env['CACHLY_JWT'] = data.api_key;
|
|
305
|
+
if (_defaultInstanceId)
|
|
306
|
+
cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
|
|
307
|
+
await _wf(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
308
|
+
}
|
|
309
|
+
catch { /* non-fatal — trial is active for this session even if persist fails */ }
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
228
316
|
// ── Embeddings (imported from embeddings.ts) ────────────────────────────────
|
|
229
317
|
import { setEmbedJwt } from './embeddings.js';
|
|
230
318
|
// ── Search Engine (imported from search.ts) ─────────────────────────────────
|
|
231
319
|
import { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS, katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP, ZERO_RESULTS_LOG, zeroResultsTotal } from './search.js';
|
|
232
320
|
// ── Exported for testing (re-export from search.ts) ─────────────────────────
|
|
233
321
|
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS, katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP };
|
|
322
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
323
|
+
// Instance is imported from handlers/brain.ts
|
|
234
324
|
// ── Connection pool ───────────────────────────────────────────────────────────
|
|
235
325
|
/** Reuse Redis connections across tool calls (keyed by instance_id). */
|
|
236
326
|
const pool = new Map();
|
|
@@ -241,16 +331,26 @@ async function getConnection(instance_id) {
|
|
|
241
331
|
'• Pass instance_id explicitly to the tool, or\n' +
|
|
242
332
|
'• Run `list_instances` to see your available instances.');
|
|
243
333
|
}
|
|
244
|
-
if (pool.has(instance_id))
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
334
|
+
if (pool.has(instance_id)) {
|
|
335
|
+
const cached = pool.get(instance_id);
|
|
336
|
+
// Verify the pooled connection is still alive before reusing it.
|
|
337
|
+
// ioredis 'error' eviction is async — a connection can be in the pool
|
|
338
|
+
// but already internally broken (status 'reconnecting' or 'end').
|
|
339
|
+
const status = cached.status;
|
|
340
|
+
if (status === 'ready' || status === 'connect') {
|
|
341
|
+
return cached;
|
|
342
|
+
}
|
|
343
|
+
// Stale — evict and fall through to reconnect.
|
|
344
|
+
pool.delete(instance_id);
|
|
345
|
+
cached.disconnect();
|
|
346
|
+
}
|
|
347
|
+
// Fetch instance, waiting up to 8 s if it is still provisioning.
|
|
348
|
+
// (autoStartSession already gave the instance a head-start; 8 s covers any remaining gap.)
|
|
249
349
|
let inst = await apiFetch(`/api/v1/instances/${instance_id}`);
|
|
250
350
|
if (inst.status === 'provisioning') {
|
|
251
|
-
const deadline = Date.now() +
|
|
351
|
+
const deadline = Date.now() + 8_000;
|
|
252
352
|
while (inst.status === 'provisioning' && Date.now() < deadline) {
|
|
253
|
-
await new Promise(r => setTimeout(r,
|
|
353
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
254
354
|
inst = await apiFetch(`/api/v1/instances/${instance_id}`);
|
|
255
355
|
}
|
|
256
356
|
}
|
|
@@ -291,7 +391,9 @@ async function getConnection(instance_id) {
|
|
|
291
391
|
}
|
|
292
392
|
// Fetch connection details (includes password) from the dedicated endpoint.
|
|
293
393
|
let password;
|
|
294
|
-
|
|
394
|
+
// Default to false — most free/dev instances run without TLS.
|
|
395
|
+
// Only enable TLS when the API explicitly returns tls_enabled: true.
|
|
396
|
+
let tlsEnabled = inst.tls_enabled === true;
|
|
295
397
|
try {
|
|
296
398
|
const conn = await apiFetch(`/api/v1/instances/${instance_id}/connection`);
|
|
297
399
|
password = conn.password ?? undefined;
|
|
@@ -314,17 +416,49 @@ async function getConnection(instance_id) {
|
|
|
314
416
|
lazyConnect: true,
|
|
315
417
|
enableReadyCheck: true,
|
|
316
418
|
connectTimeout: 8000,
|
|
419
|
+
commandTimeout: 10000,
|
|
317
420
|
retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
|
|
318
421
|
});
|
|
319
422
|
const evict = () => pool.delete(instance_id);
|
|
320
423
|
client.on('error', evict);
|
|
321
424
|
client.on('end', evict);
|
|
322
|
-
|
|
323
|
-
|
|
425
|
+
// Retry up to 3 times with 2s backoff. Covers the window between K8s PodReady
|
|
426
|
+
// and Valkey actually accepting connections (RDB/AOF load, ~1-3s on cold start).
|
|
427
|
+
let connectErr;
|
|
428
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
429
|
+
try {
|
|
430
|
+
if (attempt > 0) {
|
|
431
|
+
await new Promise(r => setTimeout(r, 2000 * attempt));
|
|
432
|
+
// Fresh client on retry — ioredis doesn't allow reconnect after explicit disconnect.
|
|
433
|
+
client.disconnect();
|
|
434
|
+
const retryClient = new Redis({
|
|
435
|
+
host: inst.host,
|
|
436
|
+
port: inst.port,
|
|
437
|
+
password: password || undefined,
|
|
438
|
+
...(tlsEnabled ? { tls: {} } : {}),
|
|
439
|
+
lazyConnect: true,
|
|
440
|
+
enableReadyCheck: true,
|
|
441
|
+
connectTimeout: 8000,
|
|
442
|
+
commandTimeout: 10000,
|
|
443
|
+
retryStrategy: () => null,
|
|
444
|
+
});
|
|
445
|
+
retryClient.on('error', () => pool.delete(instance_id));
|
|
446
|
+
retryClient.on('end', () => pool.delete(instance_id));
|
|
447
|
+
await retryClient.connect();
|
|
448
|
+
pool.set(instance_id, retryClient);
|
|
449
|
+
return retryClient;
|
|
450
|
+
}
|
|
451
|
+
await client.connect();
|
|
452
|
+
connectErr = undefined;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
connectErr = err;
|
|
457
|
+
}
|
|
324
458
|
}
|
|
325
|
-
|
|
459
|
+
if (connectErr) {
|
|
326
460
|
client.disconnect();
|
|
327
|
-
const msg =
|
|
461
|
+
const msg = connectErr instanceof Error ? connectErr.message : String(connectErr);
|
|
328
462
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
329
463
|
method: 'POST',
|
|
330
464
|
headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
|
|
@@ -392,7 +526,10 @@ function sendFunnelEvent(event, extra) {
|
|
|
392
526
|
headers: { 'Content-Type': 'application/json' },
|
|
393
527
|
body: JSON.stringify({
|
|
394
528
|
event, version: CURRENT_VERSION, editor: detectEditor(),
|
|
395
|
-
|
|
529
|
+
// Send Cachly API keys (cky_*) for server-side tenant resolution (recall counting, emails).
|
|
530
|
+
// Raw Keycloak JWTs (eyJ...) are intentionally excluded — they are short-lived bearer tokens
|
|
531
|
+
// that should never leave the MCP process boundary.
|
|
532
|
+
...(getJWT().startsWith('cky_') ? { api_key: getJWT() } : {}),
|
|
396
533
|
...extra,
|
|
397
534
|
}),
|
|
398
535
|
signal: AbortSignal.timeout(3000),
|
|
@@ -415,11 +552,18 @@ async function sendAnonymousTelemetry(toolName) {
|
|
|
415
552
|
catch { /* fire-and-forget, never block the user */ }
|
|
416
553
|
}
|
|
417
554
|
async function handleTool(name, args) {
|
|
418
|
-
// Guard: if no JWT,
|
|
555
|
+
// Guard: if no JWT, try silent auto-setup first, then fall back to device flow.
|
|
419
556
|
if (!getJWT()) {
|
|
557
|
+
// ── Step 1: Silent instant-trial (zero-click) ────────────────────────
|
|
558
|
+
// On the very first tool call, attempt to silently provision a free trial.
|
|
559
|
+
// If it succeeds the user never sees an auth prompt — Brain is just active.
|
|
560
|
+
const provisioned = await silentAutoSetup();
|
|
561
|
+
if (provisioned) {
|
|
562
|
+
return handleTool(name, args); // Transparent re-entry with active credentials
|
|
563
|
+
}
|
|
420
564
|
void sendAnonymousTelemetry(name);
|
|
421
|
-
// ── Zero-credential device flow
|
|
422
|
-
// 1st call: start device flow, return code + URL
|
|
565
|
+
// ── Step 2: Zero-credential device flow ─────────────────────────────
|
|
566
|
+
// 1st call after silent setup fails: start device flow, return code + URL
|
|
423
567
|
// 2nd+ calls: poll for token; once authenticated, proceed transparently
|
|
424
568
|
if (_deviceFlow) {
|
|
425
569
|
const result = await pollDeviceFlow(_deviceFlow);
|
|
@@ -491,7 +635,7 @@ async function handleTool(name, args) {
|
|
|
491
635
|
args = { ...args, instance_id: defaultId };
|
|
492
636
|
}
|
|
493
637
|
// Delegate brain tools (learn, recall, session, etc.)
|
|
494
|
-
|
|
638
|
+
let brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
|
|
495
639
|
if (brainResult !== null) {
|
|
496
640
|
if (!_firstCallSuccessSent && getJWT()) {
|
|
497
641
|
_firstCallSuccessSent = true;
|
|
@@ -518,28 +662,39 @@ async function handleTool(name, args) {
|
|
|
518
662
|
// Existing brain — session_start acts as implicit recall → increment counter.
|
|
519
663
|
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
520
664
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
665
|
+
// Auto-bootstrap brain_from_git when workspace_path is provided and it hasn't run yet.
|
|
666
|
+
// We check a sentinel key in Valkey so this only fires once per workspace path,
|
|
667
|
+
// not just on the very first session (handles cases where brain was empty but
|
|
668
|
+
// session data existed, or where the user re-installed).
|
|
669
|
+
if (args.workspace_path) {
|
|
524
670
|
try {
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
//
|
|
534
|
-
const
|
|
535
|
-
? gitBootstrap.content.map((c) => c.text ?? '').join('\n')
|
|
536
|
-
: String(gitBootstrap);
|
|
671
|
+
const workspacePath = args.workspace_path;
|
|
672
|
+
const autoKey = `cachly:brain_from_git:auto_done:${Buffer.from(workspacePath).toString('base64').slice(0, 40)}`;
|
|
673
|
+
const redis = await getConnection(instanceId);
|
|
674
|
+
const alreadyDone = await redis.get(autoKey);
|
|
675
|
+
if (!alreadyDone) {
|
|
676
|
+
// Mark immediately to prevent concurrent runs across multiple MCP tool calls
|
|
677
|
+
await redis.set(autoKey, '1', 'EX', 365 * 86400);
|
|
678
|
+
// Append a placeholder so the user gets immediate feedback, then bootstrap in background.
|
|
679
|
+
// Running brain_from_git synchronously would block session_start for 30-120s on large repos.
|
|
680
|
+
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.';
|
|
537
681
|
if (typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult) {
|
|
538
|
-
brainResult.content.push({
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
682
|
+
brainResult.content.push({ type: 'text', text: bgMsg });
|
|
683
|
+
}
|
|
684
|
+
else if (typeof brainResult === 'string') {
|
|
685
|
+
brainResult = brainResult + bgMsg;
|
|
542
686
|
}
|
|
687
|
+
// Fire-and-forget — never block the session_start response
|
|
688
|
+
void handleFedbrainTool('brain_from_git', {
|
|
689
|
+
instance_id: instanceId,
|
|
690
|
+
repo_path: workspacePath,
|
|
691
|
+
limit: 50,
|
|
692
|
+
}, getConnection, apiFetch).then((gitBootstrap) => {
|
|
693
|
+
if (gitBootstrap !== null) {
|
|
694
|
+
sendFunnelEvent('brain_from_git', telemetryExtra);
|
|
695
|
+
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
696
|
+
}
|
|
697
|
+
}).catch(() => { });
|
|
543
698
|
}
|
|
544
699
|
}
|
|
545
700
|
catch { /* git bootstrap errors must never break session_start */ }
|
|
@@ -642,27 +797,38 @@ async function handleTool(name, args) {
|
|
|
642
797
|
].join('\n');
|
|
643
798
|
}
|
|
644
799
|
// Decode JWT claims (inspection only, no verification)
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
654
|
-
const expired = expTs ? expTs < new Date() : false;
|
|
655
|
-
authInfo = [
|
|
656
|
-
`✅ JWT decoded`,
|
|
657
|
-
` Sub (user ID): ${sub}`,
|
|
658
|
-
` Provider: ${provider}`,
|
|
659
|
-
` Issuer: ${iss}`,
|
|
660
|
-
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
|
|
661
|
-
].join('\n');
|
|
662
|
-
}
|
|
800
|
+
const jwt = getJWT();
|
|
801
|
+
let authInfo;
|
|
802
|
+
// API keys (cky_live_... / cky_trial_...) are valid long-lived credentials.
|
|
803
|
+
// Only try to decode as JWT (3-part base64url) if it looks like one.
|
|
804
|
+
if (jwt.startsWith('cky_')) {
|
|
805
|
+
const keyType = jwt.startsWith('cky_trial_') ? 'Trial' : 'Live';
|
|
806
|
+
const keyId = jwt.slice(0, 20) + '…';
|
|
807
|
+
authInfo = `✅ API key (${keyType})\n Key prefix: ${keyId}`;
|
|
663
808
|
}
|
|
664
|
-
|
|
665
|
-
authInfo = '❌
|
|
809
|
+
else {
|
|
810
|
+
authInfo = '❌ invalid credential format';
|
|
811
|
+
try {
|
|
812
|
+
const parts = jwt.split('.');
|
|
813
|
+
if (parts.length === 3) {
|
|
814
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
|
815
|
+
const sub = payload.sub ?? '(unknown)';
|
|
816
|
+
const iss = payload.iss ?? '(unknown)';
|
|
817
|
+
const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
|
|
818
|
+
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
819
|
+
const expired = expTs ? expTs < new Date() : false;
|
|
820
|
+
authInfo = [
|
|
821
|
+
`✅ JWT decoded`,
|
|
822
|
+
` Sub (user ID): ${sub}`,
|
|
823
|
+
` Provider: ${provider}`,
|
|
824
|
+
` Issuer: ${iss}`,
|
|
825
|
+
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
|
|
826
|
+
].join('\n');
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
831
|
+
}
|
|
666
832
|
}
|
|
667
833
|
// List instances + connectivity
|
|
668
834
|
let instanceInfo = '';
|
|
@@ -804,13 +970,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
804
970
|
|| _defaultInstanceId
|
|
805
971
|
|| process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
806
972
|
if (instanceId) {
|
|
807
|
-
|
|
973
|
+
// Fire-and-forget — session management is non-critical and must never block tool calls.
|
|
974
|
+
// The session_start briefing (which includes brain_from_git) can take 30-120s on large
|
|
975
|
+
// repos; awaiting it here would freeze the user's first MCP tool call entirely.
|
|
976
|
+
void autoStartSession(instanceId);
|
|
808
977
|
}
|
|
809
978
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
await _autoSessionStarting.catch(() => undefined);
|
|
813
|
-
}
|
|
979
|
+
// Note: we intentionally do NOT await _autoSessionStarting for any call.
|
|
980
|
+
// The session tracker runs in background — tool calls must always proceed immediately.
|
|
814
981
|
if (!sessionTools.has(name))
|
|
815
982
|
_autoSessionToolCount++;
|
|
816
983
|
try {
|
|
@@ -1808,7 +1975,7 @@ if (process.argv[2] === 'status') {
|
|
|
1808
1975
|
if (process.argv[2] === 'health') {
|
|
1809
1976
|
const { existsSync } = await import('node:fs');
|
|
1810
1977
|
const { readFile } = await import('node:fs/promises');
|
|
1811
|
-
const { resolve
|
|
1978
|
+
const { resolve } = await import('node:path');
|
|
1812
1979
|
const cwd = process.cwd();
|
|
1813
1980
|
let passed = 0;
|
|
1814
1981
|
let failed = 0;
|
|
@@ -1822,6 +1989,10 @@ if (process.argv[2] === 'health') {
|
|
|
1822
1989
|
if (!jwt) {
|
|
1823
1990
|
fail('CACHLY_JWT not set — run: npx @cachly-dev/mcp-server@latest setup');
|
|
1824
1991
|
}
|
|
1992
|
+
else if (jwt.startsWith('cky_')) {
|
|
1993
|
+
// Opaque cachly API key — no JWT expiry to check, format is valid by prefix
|
|
1994
|
+
ok('API key set (cky_*** opaque key)');
|
|
1995
|
+
}
|
|
1825
1996
|
else {
|
|
1826
1997
|
try {
|
|
1827
1998
|
const parts = jwt.split('.');
|
|
@@ -1841,7 +2012,7 @@ if (process.argv[2] === 'health') {
|
|
|
1841
2012
|
}
|
|
1842
2013
|
}
|
|
1843
2014
|
catch {
|
|
1844
|
-
fail('CACHLY_JWT format invalid (expected JWT with 3 parts)');
|
|
2015
|
+
fail('CACHLY_JWT format invalid (expected a cky_*** API key or a JWT with 3 parts)');
|
|
1845
2016
|
}
|
|
1846
2017
|
}
|
|
1847
2018
|
// ── 2. Brain API reachability ───────────────────────────────────────────────
|
|
@@ -1892,11 +2063,27 @@ if (process.argv[2] === 'health') {
|
|
|
1892
2063
|
});
|
|
1893
2064
|
if (connRes.ok) {
|
|
1894
2065
|
const conn = await connRes.json();
|
|
1895
|
-
|
|
1896
|
-
|
|
2066
|
+
// password may be a direct field, or embedded in connection_string as redis://:pass@host:port
|
|
2067
|
+
if (conn.password) {
|
|
2068
|
+
pingPassword = conn.password;
|
|
2069
|
+
}
|
|
2070
|
+
else if (conn.connection_string) {
|
|
2071
|
+
try {
|
|
2072
|
+
const u = new URL(conn.connection_string);
|
|
2073
|
+
if (u.password)
|
|
2074
|
+
pingPassword = decodeURIComponent(u.password);
|
|
2075
|
+
}
|
|
2076
|
+
catch { /* leave pingPassword undefined */ }
|
|
2077
|
+
}
|
|
2078
|
+
pingTls = conn.tls_enabled === true;
|
|
2079
|
+
}
|
|
2080
|
+
else {
|
|
2081
|
+
warn(`Could not fetch Redis credentials (HTTP ${connRes.status}) — connectivity test may fail`);
|
|
1897
2082
|
}
|
|
1898
2083
|
}
|
|
1899
|
-
catch {
|
|
2084
|
+
catch (connErr) {
|
|
2085
|
+
warn(`Could not fetch Redis credentials — ${connErr.message}`);
|
|
2086
|
+
}
|
|
1900
2087
|
const pingClient = new Redis({
|
|
1901
2088
|
host: inst.host, port: inst.port,
|
|
1902
2089
|
password: pingPassword,
|
|
@@ -2127,7 +2314,7 @@ if (process.argv[2] === 'setup') {
|
|
|
2127
2314
|
token = keyBody.key; // swap JWT → cky_live_...
|
|
2128
2315
|
console.log(' ✓\n');
|
|
2129
2316
|
}
|
|
2130
|
-
catch
|
|
2317
|
+
catch {
|
|
2131
2318
|
console.log(' (skipped)\n');
|
|
2132
2319
|
// Non-fatal: fall back to using the Keycloak JWT directly.
|
|
2133
2320
|
// It will expire but setup still works for now.
|
|
@@ -2522,7 +2709,7 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2522
2709
|
limit: maxCommits,
|
|
2523
2710
|
});
|
|
2524
2711
|
console.log(result);
|
|
2525
|
-
sendFunnelEvent('brain_from_git', {
|
|
2712
|
+
sendFunnelEvent('brain_from_git', { instance_id: instanceId });
|
|
2526
2713
|
console.log('\n✅ Brain learned from your commits.\n');
|
|
2527
2714
|
// Let fire-and-forget telemetry flush before exit.
|
|
2528
2715
|
await new Promise(r => setTimeout(r, 300));
|
|
@@ -2603,6 +2790,27 @@ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
|
|
|
2603
2790
|
})();
|
|
2604
2791
|
}
|
|
2605
2792
|
const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
2793
|
+
// ── Per-request MCP Server factory (HTTP / Smithery stateless mode) ──────────
|
|
2794
|
+
// SDK ≥1.9 forbids reusing a stateless transport across requests.
|
|
2795
|
+
// Solution: create a fresh Server+Transport per /mcp request.
|
|
2796
|
+
// The module-level `server` below is used for stdio mode only.
|
|
2797
|
+
function createHttpMcpServer() {
|
|
2798
|
+
const srv = new Server({ name: 'cachly-mcp', version: CURRENT_VERSION }, { capabilities: { tools: {} } });
|
|
2799
|
+
srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
2800
|
+
srv.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2801
|
+
const { name, arguments: args } = request.params;
|
|
2802
|
+
try {
|
|
2803
|
+
const text = await handleTool(name, (args ?? {}));
|
|
2804
|
+
return { content: [{ type: 'text', text }] };
|
|
2805
|
+
}
|
|
2806
|
+
catch (err) {
|
|
2807
|
+
if (err instanceof McpError)
|
|
2808
|
+
throw err;
|
|
2809
|
+
throw new McpError(ErrorCode.InternalError, err.message);
|
|
2810
|
+
}
|
|
2811
|
+
});
|
|
2812
|
+
return srv;
|
|
2813
|
+
}
|
|
2606
2814
|
if (httpPort) {
|
|
2607
2815
|
// ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
|
|
2608
2816
|
// Used for Smithery URL deployment: PORT=3000 node dist/index.js
|
|
@@ -2611,14 +2819,19 @@ if (httpPort) {
|
|
|
2611
2819
|
// 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
|
|
2612
2820
|
// Falls back to module-level env-derived values when neither is provided.
|
|
2613
2821
|
const { createServer } = await import('node:http');
|
|
2614
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
2615
|
-
await server.connect(transport);
|
|
2616
2822
|
function extractReqCtx(req, url) {
|
|
2617
2823
|
let jwtVal = '';
|
|
2618
2824
|
let instId = '';
|
|
2619
2825
|
const auth = req.headers['authorization'];
|
|
2620
|
-
if (typeof auth === 'string' && auth.
|
|
2621
|
-
|
|
2826
|
+
if (typeof auth === 'string' && auth.trim()) {
|
|
2827
|
+
const a = auth.trim();
|
|
2828
|
+
jwtVal = a.toLowerCase().startsWith('bearer ') ? a.slice(7).trim() : a;
|
|
2829
|
+
}
|
|
2830
|
+
// Fallback: raw JWT in custom header (Smithery "header" transport without Bearer prefix)
|
|
2831
|
+
if (!jwtVal) {
|
|
2832
|
+
const rawJwt = req.headers['cachly_jwt'] ?? req.headers['cachly-jwt'] ?? req.headers['x-cachly-jwt'] ?? req.headers['x-api-key'];
|
|
2833
|
+
if (typeof rawJwt === 'string')
|
|
2834
|
+
jwtVal = rawJwt.trim();
|
|
2622
2835
|
}
|
|
2623
2836
|
const hdrInst = req.headers['x-cachly-instance-id'];
|
|
2624
2837
|
if (typeof hdrInst === 'string')
|
|
@@ -2637,6 +2850,17 @@ if (httpPort) {
|
|
|
2637
2850
|
}
|
|
2638
2851
|
catch { /* malformed config — ignore, fall back to globals */ }
|
|
2639
2852
|
}
|
|
2853
|
+
// Direct query params (Smithery "query" transport, manual clients, etc.)
|
|
2854
|
+
if (!jwtVal) {
|
|
2855
|
+
const q = url.searchParams.get('CACHLY_JWT') ?? url.searchParams.get('cachly_jwt') ?? url.searchParams.get('api_key');
|
|
2856
|
+
if (q)
|
|
2857
|
+
jwtVal = q.trim();
|
|
2858
|
+
}
|
|
2859
|
+
if (!instId) {
|
|
2860
|
+
const q = url.searchParams.get('CACHLY_BRAIN_INSTANCE_ID') ?? url.searchParams.get('cachly_brain_instance_id') ?? url.searchParams.get('instance_id');
|
|
2861
|
+
if (q)
|
|
2862
|
+
instId = q.trim();
|
|
2863
|
+
}
|
|
2640
2864
|
return { jwt: jwtVal, instanceId: instId };
|
|
2641
2865
|
}
|
|
2642
2866
|
const httpServer = createServer((req, res) => {
|
|
@@ -2655,16 +2879,51 @@ if (httpPort) {
|
|
|
2655
2879
|
}));
|
|
2656
2880
|
return;
|
|
2657
2881
|
}
|
|
2882
|
+
// Static server card — lets Smithery skip live scanning (avoids auth complexity).
|
|
2883
|
+
if (url.pathname === '/.well-known/mcp/server-card.json') {
|
|
2884
|
+
const card = {
|
|
2885
|
+
name: 'cachly AI Brain',
|
|
2886
|
+
version: CURRENT_VERSION,
|
|
2887
|
+
description: 'Persistent AI memory for Claude Code, Cursor, Copilot, Windsurf & friends. Remembers every lesson, fix, and architecture decision forever.',
|
|
2888
|
+
transport: { type: 'streamable-http', url: 'https://mcp.cachly.dev/mcp' },
|
|
2889
|
+
capabilities: { tools: {} },
|
|
2890
|
+
};
|
|
2891
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
2892
|
+
res.end(JSON.stringify(card));
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2658
2895
|
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
2896
|
+
// Handle CORS preflight
|
|
2897
|
+
if (req.method === 'OPTIONS') {
|
|
2898
|
+
res.writeHead(204, {
|
|
2899
|
+
'Access-Control-Allow-Origin': '*',
|
|
2900
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
2901
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, X-Cachly-Instance-Id, cachly_jwt, cachly-jwt, x-cachly-jwt, x-api-key',
|
|
2902
|
+
});
|
|
2903
|
+
res.end();
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2659
2906
|
const ctx = extractReqCtx(req, url);
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2907
|
+
// Create a fresh Server+Transport for each request (stateless multi-tenant mode).
|
|
2908
|
+
// SDK ≥1.9 throws if a stateless transport is reused across requests.
|
|
2909
|
+
const reqServer = createHttpMcpServer();
|
|
2910
|
+
const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
2911
|
+
reqServer.connect(reqTransport).then(() => {
|
|
2912
|
+
reqCtx.run(ctx, () => {
|
|
2913
|
+
reqTransport.handleRequest(req, res).catch((err) => {
|
|
2914
|
+
process.stderr.write(`cachly-mcp HTTP handler error: ${err.message}\n`);
|
|
2915
|
+
if (!res.headersSent) {
|
|
2916
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2917
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
2918
|
+
}
|
|
2919
|
+
});
|
|
2667
2920
|
});
|
|
2921
|
+
}).catch((err) => {
|
|
2922
|
+
process.stderr.write(`cachly-mcp connect error: ${err.message}\n`);
|
|
2923
|
+
if (!res.headersSent) {
|
|
2924
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2925
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
2926
|
+
}
|
|
2668
2927
|
});
|
|
2669
2928
|
return;
|
|
2670
2929
|
}
|