@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/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 EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
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 < 30_000)
75
- return '';
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();
@@ -254,14 +344,13 @@ async function getConnection(instance_id) {
254
344
  pool.delete(instance_id);
255
345
  cached.disconnect();
256
346
  }
257
- // Fetch instance, waiting up to 25 s if it is still provisioning.
258
- // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
259
- // all happen in quick succession and the instance isn't running yet.
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.)
260
349
  let inst = await apiFetch(`/api/v1/instances/${instance_id}`);
261
350
  if (inst.status === 'provisioning') {
262
- const deadline = Date.now() + 25_000;
351
+ const deadline = Date.now() + 8_000;
263
352
  while (inst.status === 'provisioning' && Date.now() < deadline) {
264
- await new Promise(r => setTimeout(r, 3000));
353
+ await new Promise(r => setTimeout(r, 2000));
265
354
  inst = await apiFetch(`/api/v1/instances/${instance_id}`);
266
355
  }
267
356
  }
@@ -302,7 +391,9 @@ async function getConnection(instance_id) {
302
391
  }
303
392
  // Fetch connection details (includes password) from the dedicated endpoint.
304
393
  let password;
305
- let tlsEnabled = inst.tls_enabled !== false; // default true
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;
306
397
  try {
307
398
  const conn = await apiFetch(`/api/v1/instances/${instance_id}/connection`);
308
399
  password = conn.password ?? undefined;
@@ -325,6 +416,7 @@ async function getConnection(instance_id) {
325
416
  lazyConnect: true,
326
417
  enableReadyCheck: true,
327
418
  connectTimeout: 8000,
419
+ commandTimeout: 10000,
328
420
  retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
329
421
  });
330
422
  const evict = () => pool.delete(instance_id);
@@ -347,6 +439,7 @@ async function getConnection(instance_id) {
347
439
  lazyConnect: true,
348
440
  enableReadyCheck: true,
349
441
  connectTimeout: 8000,
442
+ commandTimeout: 10000,
350
443
  retryStrategy: () => null,
351
444
  });
352
445
  retryClient.on('error', () => pool.delete(instance_id));
@@ -433,7 +526,10 @@ function sendFunnelEvent(event, extra) {
433
526
  headers: { 'Content-Type': 'application/json' },
434
527
  body: JSON.stringify({
435
528
  event, version: CURRENT_VERSION, editor: detectEditor(),
436
- ...(getJWT() ? { jwt: getJWT() } : {}),
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() } : {}),
437
533
  ...extra,
438
534
  }),
439
535
  signal: AbortSignal.timeout(3000),
@@ -456,11 +552,18 @@ async function sendAnonymousTelemetry(toolName) {
456
552
  catch { /* fire-and-forget, never block the user */ }
457
553
  }
458
554
  async function handleTool(name, args) {
459
- // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
555
+ // Guard: if no JWT, try silent auto-setup first, then fall back to device flow.
460
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
+ }
461
564
  void sendAnonymousTelemetry(name);
462
- // ── Zero-credential device flow ─────────────────────────────────────
463
- // 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
464
567
  // 2nd+ calls: poll for token; once authenticated, proceed transparently
465
568
  if (_deviceFlow) {
466
569
  const result = await pollDeviceFlow(_deviceFlow);
@@ -532,7 +635,7 @@ async function handleTool(name, args) {
532
635
  args = { ...args, instance_id: defaultId };
533
636
  }
534
637
  // Delegate brain tools (learn, recall, session, etc.)
535
- const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
638
+ let brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
536
639
  if (brainResult !== null) {
537
640
  if (!_firstCallSuccessSent && getJWT()) {
538
641
  _firstCallSuccessSent = true;
@@ -559,28 +662,39 @@ async function handleTool(name, args) {
559
662
  // Existing brain — session_start acts as implicit recall → increment counter.
560
663
  sendFunnelEvent('recall_best_solution', telemetryExtra);
561
664
  }
562
- else if (isFirstSession && args.workspace_path) {
563
- // First session with a known workspace: auto-bootstrap from git history.
564
- // Runs synchronously so the user sees the result in the same response.
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) {
565
670
  try {
566
- const gitBootstrap = await handleFedbrainTool('brain_from_git', {
567
- instance_id: instanceId,
568
- repo_path: args.workspace_path,
569
- limit: 50,
570
- }, getConnection, apiFetch);
571
- if (gitBootstrap !== null) {
572
- sendFunnelEvent('brain_from_git', telemetryExtra);
573
- sendFunnelEvent('recall_best_solution', telemetryExtra);
574
- // Append bootstrap summary to the session_start briefing.
575
- const bootstrapText = typeof gitBootstrap === 'object' && gitBootstrap !== null && 'content' in gitBootstrap
576
- ? gitBootstrap.content.map((c) => c.text ?? '').join('\n')
577
- : 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.';
578
681
  if (typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult) {
579
- brainResult.content.push({
580
- type: 'text',
581
- text: '\n---\n' + bootstrapText,
582
- });
682
+ brainResult.content.push({ type: 'text', text: bgMsg });
683
+ }
684
+ else if (typeof brainResult === 'string') {
685
+ brainResult = brainResult + bgMsg;
583
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(() => { });
584
698
  }
585
699
  }
586
700
  catch { /* git bootstrap errors must never break session_start */ }
@@ -683,27 +797,38 @@ async function handleTool(name, args) {
683
797
  ].join('\n');
684
798
  }
685
799
  // Decode JWT claims (inspection only, no verification)
686
- let authInfo = '❌ invalid JWT format';
687
- try {
688
- const parts = getJWT().split('.');
689
- if (parts.length === 3) {
690
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
691
- const sub = payload.sub ?? '(unknown)';
692
- const iss = payload.iss ?? '(unknown)';
693
- const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
694
- const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
695
- const expired = expTs ? expTs < new Date() : false;
696
- authInfo = [
697
- `✅ JWT decoded`,
698
- ` Sub (user ID): ${sub}`,
699
- ` Provider: ${provider}`,
700
- ` Issuer: ${iss}`,
701
- ` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
702
- ].join('\n');
703
- }
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}`;
704
808
  }
705
- catch {
706
- authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
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
+ }
707
832
  }
708
833
  // List instances + connectivity
709
834
  let instanceInfo = '';
@@ -845,13 +970,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
845
970
  || _defaultInstanceId
846
971
  || process.env.CACHLY_BRAIN_INSTANCE_ID;
847
972
  if (instanceId) {
848
- await autoStartSession(instanceId).catch(() => undefined);
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);
849
977
  }
850
978
  }
851
- else if (_autoSessionStarting) {
852
- // Another concurrent call is mid-start — wait for it before proceeding.
853
- await _autoSessionStarting.catch(() => undefined);
854
- }
979
+ // Note: we intentionally do NOT await _autoSessionStarting for any call.
980
+ // The session tracker runs in background — tool calls must always proceed immediately.
855
981
  if (!sessionTools.has(name))
856
982
  _autoSessionToolCount++;
857
983
  try {
@@ -1849,7 +1975,7 @@ if (process.argv[2] === 'status') {
1849
1975
  if (process.argv[2] === 'health') {
1850
1976
  const { existsSync } = await import('node:fs');
1851
1977
  const { readFile } = await import('node:fs/promises');
1852
- const { resolve, join: pJoin } = await import('node:path');
1978
+ const { resolve } = await import('node:path');
1853
1979
  const cwd = process.cwd();
1854
1980
  let passed = 0;
1855
1981
  let failed = 0;
@@ -1863,6 +1989,10 @@ if (process.argv[2] === 'health') {
1863
1989
  if (!jwt) {
1864
1990
  fail('CACHLY_JWT not set — run: npx @cachly-dev/mcp-server@latest setup');
1865
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
+ }
1866
1996
  else {
1867
1997
  try {
1868
1998
  const parts = jwt.split('.');
@@ -1882,7 +2012,7 @@ if (process.argv[2] === 'health') {
1882
2012
  }
1883
2013
  }
1884
2014
  catch {
1885
- 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)');
1886
2016
  }
1887
2017
  }
1888
2018
  // ── 2. Brain API reachability ───────────────────────────────────────────────
@@ -1933,11 +2063,27 @@ if (process.argv[2] === 'health') {
1933
2063
  });
1934
2064
  if (connRes.ok) {
1935
2065
  const conn = await connRes.json();
1936
- pingPassword = conn.password;
1937
- pingTls = conn.tls_enabled !== false;
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;
1938
2079
  }
2080
+ else {
2081
+ warn(`Could not fetch Redis credentials (HTTP ${connRes.status}) — connectivity test may fail`);
2082
+ }
2083
+ }
2084
+ catch (connErr) {
2085
+ warn(`Could not fetch Redis credentials — ${connErr.message}`);
1939
2086
  }
1940
- catch { /* proceed without password */ }
1941
2087
  const pingClient = new Redis({
1942
2088
  host: inst.host, port: inst.port,
1943
2089
  password: pingPassword,
@@ -2168,7 +2314,7 @@ if (process.argv[2] === 'setup') {
2168
2314
  token = keyBody.key; // swap JWT → cky_live_...
2169
2315
  console.log(' ✓\n');
2170
2316
  }
2171
- catch (e) {
2317
+ catch {
2172
2318
  console.log(' (skipped)\n');
2173
2319
  // Non-fatal: fall back to using the Keycloak JWT directly.
2174
2320
  // It will expire but setup still works for now.
@@ -2563,7 +2709,7 @@ if (process.argv[2] === 'learn-git') {
2563
2709
  limit: maxCommits,
2564
2710
  });
2565
2711
  console.log(result);
2566
- sendFunnelEvent('brain_from_git', { api_key: JWT, instance_id: instanceId });
2712
+ sendFunnelEvent('brain_from_git', { instance_id: instanceId });
2567
2713
  console.log('\n✅ Brain learned from your commits.\n');
2568
2714
  // Let fire-and-forget telemetry flush before exit.
2569
2715
  await new Promise(r => setTimeout(r, 300));