@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/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();
@@ -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
- return pool.get(instance_id);
246
- // Fetch instance, waiting up to 25 s if it is still provisioning.
247
- // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
248
- // all happen in quick succession and the instance isn't running yet.
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() + 25_000;
351
+ const deadline = Date.now() + 8_000;
252
352
  while (inst.status === 'provisioning' && Date.now() < deadline) {
253
- await new Promise(r => setTimeout(r, 3000));
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
- 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;
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
- try {
323
- await client.connect();
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
- catch (err) {
459
+ if (connectErr) {
326
460
  client.disconnect();
327
- const msg = err instanceof Error ? err.message : String(err);
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
- ...(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() } : {}),
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, return actionable onboarding message instead of HTTP 401
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
- const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
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
- else if (isFirstSession && args.workspace_path) {
522
- // First session with a known workspace: auto-bootstrap from git history.
523
- // 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) {
524
670
  try {
525
- const gitBootstrap = await handleFedbrainTool('brain_from_git', {
526
- instance_id: instanceId,
527
- repo_path: args.workspace_path,
528
- limit: 50,
529
- }, getConnection, apiFetch);
530
- if (gitBootstrap !== null) {
531
- sendFunnelEvent('brain_from_git', telemetryExtra);
532
- sendFunnelEvent('recall_best_solution', telemetryExtra);
533
- // Append bootstrap summary to the session_start briefing.
534
- const bootstrapText = typeof gitBootstrap === 'object' && gitBootstrap !== null && 'content' in gitBootstrap
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
- type: 'text',
540
- text: '\n---\n' + bootstrapText,
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
- let authInfo = '❌ invalid JWT format';
646
- try {
647
- const parts = getJWT().split('.');
648
- if (parts.length === 3) {
649
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
650
- const sub = payload.sub ?? '(unknown)';
651
- const iss = payload.iss ?? '(unknown)';
652
- const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
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
- catch {
665
- 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
+ }
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
- 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);
808
977
  }
809
978
  }
810
- else if (_autoSessionStarting) {
811
- // Another concurrent call is mid-start — wait for it before proceeding.
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, join: pJoin } = await import('node:path');
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
- pingPassword = conn.password;
1896
- 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;
2079
+ }
2080
+ else {
2081
+ warn(`Could not fetch Redis credentials (HTTP ${connRes.status}) — connectivity test may fail`);
1897
2082
  }
1898
2083
  }
1899
- catch { /* proceed without password */ }
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 (e) {
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', { api_key: JWT, instance_id: instanceId });
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.toLowerCase().startsWith('bearer ')) {
2621
- jwtVal = auth.slice(7).trim();
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
- reqCtx.run(ctx, () => {
2661
- transport.handleRequest(req, res).catch((err) => {
2662
- process.stderr.write(`cachly-mcp HTTP handler error: ${err.message}\n`);
2663
- if (!res.headersSent) {
2664
- res.writeHead(500, { 'Content-Type': 'application/json' });
2665
- res.end(JSON.stringify({ error: 'internal_error' }));
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
  }