@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/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 EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
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 < 30_000) return '';
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
- // ── Confidence utils (imported from confidence.ts) ──────────────────────────
247
- import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES,
248
- CONFIDENCE_WARN_VALUE, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_DAYS,
249
- CONFIDENCE_STALE_DAYS,
250
- simpleHash } from './confidence.js';
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 { embedConfig, setEmbedJwt, EMBED_PROVIDER, computeEmbedding,
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
- keywordSearch, ZERO_RESULTS_LOG, zeroResultsTotal, indexVocab as _indexVocab } from './search.js';
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 any).status as string | undefined;
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 25 s if it is still provisioning.
314
- // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
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() + 25_000;
370
+ const deadline = Date.now() + 8_000;
319
371
  while (inst.status === 'provisioning' && Date.now() < deadline) {
320
- await new Promise(r => setTimeout(r, 3000));
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
- let tlsEnabled = inst.tls_enabled !== false; // default true
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
- ...(getJWT() ? { jwt: getJWT() } : {}),
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, return actionable onboarding message instead of HTTP 401
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
- const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
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
- } else if (isFirstSession && args.workspace_path) {
640
- // First session with a known workspace: auto-bootstrap from git history.
641
- // Runs synchronously so the user sees the result in the same response.
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 gitBootstrap = await handleFedbrainTool('brain_from_git', {
644
- instance_id: instanceId,
645
- repo_path: args.workspace_path as string,
646
- limit: 50,
647
- }, getConnection, apiFetch);
648
- if (gitBootstrap !== null) {
649
- sendFunnelEvent('brain_from_git', telemetryExtra);
650
- sendFunnelEvent('recall_best_solution', telemetryExtra);
651
- // Append bootstrap summary to the session_start briefing.
652
- const bootstrapText = typeof gitBootstrap === 'object' && gitBootstrap !== null && 'content' in gitBootstrap
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
- type: 'text',
658
- text: '\n---\n' + bootstrapText,
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
- let authInfo = '❌ invalid JWT format';
764
- try {
765
- const parts = getJWT().split('.');
766
- if (parts.length === 3) {
767
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
768
- sub?: string; exp?: number; iss?: string;
769
- };
770
- const sub = payload.sub ?? '(unknown)';
771
- const iss = payload.iss ?? '(unknown)';
772
- const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
773
- const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
774
- const expired = expTs ? expTs < new Date() : false;
775
- authInfo = [
776
- `✅ JWT decoded`,
777
- ` Sub (user ID): ${sub}`,
778
- ` Provider: ${provider}`,
779
- ` Issuer: ${iss}`,
780
- ` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
781
- ].join('\n');
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
- await autoStartSession(instanceId).catch(() => undefined);
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, join: pJoin } = await import('node:path');
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
- pingPassword = conn.password;
2085
- pingTls = conn.tls_enabled !== false;
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 { /* proceed without password */ }
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 (e) {
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', { api_key: JWT, instance_id: instanceId });
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));
@@ -2780,6 +2881,29 @@ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
2780
2881
 
2781
2882
  const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
2782
2883
 
2884
+ // ── Per-request MCP Server factory (HTTP / Smithery stateless mode) ──────────
2885
+ // SDK ≥1.9 forbids reusing a stateless transport across requests.
2886
+ // Solution: create a fresh Server+Transport per /mcp request.
2887
+ // The module-level `server` below is used for stdio mode only.
2888
+ function createHttpMcpServer(): Server {
2889
+ const srv = new Server(
2890
+ { name: 'cachly-mcp', version: CURRENT_VERSION },
2891
+ { capabilities: { tools: {} } }
2892
+ );
2893
+ srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
2894
+ srv.setRequestHandler(CallToolRequestSchema, async (request) => {
2895
+ const { name, arguments: args } = request.params;
2896
+ try {
2897
+ const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
2898
+ return { content: [{ type: 'text', text }] };
2899
+ } catch (err) {
2900
+ if (err instanceof McpError) throw err;
2901
+ throw new McpError(ErrorCode.InternalError, (err as Error).message);
2902
+ }
2903
+ });
2904
+ return srv;
2905
+ }
2906
+
2783
2907
  if (httpPort) {
2784
2908
  // ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
2785
2909
  // Used for Smithery URL deployment: PORT=3000 node dist/index.js
@@ -2788,15 +2912,19 @@ if (httpPort) {
2788
2912
  // 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
2789
2913
  // Falls back to module-level env-derived values when neither is provided.
2790
2914
  const { createServer } = await import('node:http');
2791
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
2792
- await server.connect(transport);
2793
2915
 
2794
2916
  function extractReqCtx(req: import('node:http').IncomingMessage, url: URL): ReqCtx {
2795
2917
  let jwtVal = '';
2796
2918
  let instId = '';
2797
2919
  const auth = req.headers['authorization'];
2798
- if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) {
2799
- jwtVal = auth.slice(7).trim();
2920
+ if (typeof auth === 'string' && auth.trim()) {
2921
+ const a = auth.trim();
2922
+ jwtVal = a.toLowerCase().startsWith('bearer ') ? a.slice(7).trim() : a;
2923
+ }
2924
+ // Fallback: raw JWT in custom header (Smithery "header" transport without Bearer prefix)
2925
+ if (!jwtVal) {
2926
+ const rawJwt = req.headers['cachly_jwt'] ?? req.headers['cachly-jwt'] ?? req.headers['x-cachly-jwt'] ?? req.headers['x-api-key'];
2927
+ if (typeof rawJwt === 'string') jwtVal = rawJwt.trim();
2800
2928
  }
2801
2929
  const hdrInst = req.headers['x-cachly-instance-id'];
2802
2930
  if (typeof hdrInst === 'string') instId = hdrInst.trim();
@@ -2811,6 +2939,15 @@ if (httpPort) {
2811
2939
  if (!instId && typeof json.CACHLY_BRAIN_INSTANCE_ID === 'string') instId = json.CACHLY_BRAIN_INSTANCE_ID;
2812
2940
  } catch { /* malformed config — ignore, fall back to globals */ }
2813
2941
  }
2942
+ // Direct query params (Smithery "query" transport, manual clients, etc.)
2943
+ if (!jwtVal) {
2944
+ const q = url.searchParams.get('CACHLY_JWT') ?? url.searchParams.get('cachly_jwt') ?? url.searchParams.get('api_key');
2945
+ if (q) jwtVal = q.trim();
2946
+ }
2947
+ if (!instId) {
2948
+ const q = url.searchParams.get('CACHLY_BRAIN_INSTANCE_ID') ?? url.searchParams.get('cachly_brain_instance_id') ?? url.searchParams.get('instance_id');
2949
+ if (q) instId = q.trim();
2950
+ }
2814
2951
  return { jwt: jwtVal, instanceId: instId };
2815
2952
  }
2816
2953
 
@@ -2830,16 +2967,51 @@ if (httpPort) {
2830
2967
  }));
2831
2968
  return;
2832
2969
  }
2970
+ // Static server card — lets Smithery skip live scanning (avoids auth complexity).
2971
+ if (url.pathname === '/.well-known/mcp/server-card.json') {
2972
+ const card = {
2973
+ name: 'cachly AI Brain',
2974
+ version: CURRENT_VERSION,
2975
+ description: 'Persistent AI memory for Claude Code, Cursor, Copilot, Windsurf & friends. Remembers every lesson, fix, and architecture decision forever.',
2976
+ transport: { type: 'streamable-http', url: 'https://mcp.cachly.dev/mcp' },
2977
+ capabilities: { tools: {} },
2978
+ };
2979
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
2980
+ res.end(JSON.stringify(card));
2981
+ return;
2982
+ }
2833
2983
  if (url.pathname === '/mcp' || url.pathname === '/') {
2984
+ // Handle CORS preflight
2985
+ if (req.method === 'OPTIONS') {
2986
+ res.writeHead(204, {
2987
+ 'Access-Control-Allow-Origin': '*',
2988
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
2989
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, X-Cachly-Instance-Id, cachly_jwt, cachly-jwt, x-cachly-jwt, x-api-key',
2990
+ });
2991
+ res.end();
2992
+ return;
2993
+ }
2834
2994
  const ctx = extractReqCtx(req, url);
2835
- reqCtx.run(ctx, () => {
2836
- transport.handleRequest(req, res).catch((err) => {
2837
- process.stderr.write(`cachly-mcp HTTP handler error: ${(err as Error).message}\n`);
2838
- if (!res.headersSent) {
2839
- res.writeHead(500, { 'Content-Type': 'application/json' });
2840
- res.end(JSON.stringify({ error: 'internal_error' }));
2841
- }
2995
+ // Create a fresh Server+Transport for each request (stateless multi-tenant mode).
2996
+ // SDK ≥1.9 throws if a stateless transport is reused across requests.
2997
+ const reqServer = createHttpMcpServer();
2998
+ const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
2999
+ reqServer.connect(reqTransport).then(() => {
3000
+ reqCtx.run(ctx, () => {
3001
+ reqTransport.handleRequest(req, res).catch((err) => {
3002
+ process.stderr.write(`cachly-mcp HTTP handler error: ${(err as Error).message}\n`);
3003
+ if (!res.headersSent) {
3004
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3005
+ res.end(JSON.stringify({ error: 'internal_error' }));
3006
+ }
3007
+ });
2842
3008
  });
3009
+ }).catch((err) => {
3010
+ process.stderr.write(`cachly-mcp connect error: ${(err as Error).message}\n`);
3011
+ if (!res.headersSent) {
3012
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3013
+ res.end(JSON.stringify({ error: 'internal_error' }));
3014
+ }
2843
3015
  });
2844
3016
  return;
2845
3017
  }
package/src/search.ts CHANGED
@@ -384,7 +384,7 @@ function hindiLightStem(word: string): string {
384
384
  * настройки → настройк (genitive plural)
385
385
  */
386
386
  function russianLightStem(word: string): string {
387
- let w = word;
387
+ const w = word;
388
388
  // Longest first to avoid partial stripping
389
389
  // Verb infinitives / gerunds
390
390
  if (w.endsWith('ывание') && w.length > 7) return w.slice(0, -6);
@@ -427,7 +427,7 @@ function russianLightStem(word: string): string {
427
427
  * yüklemek → yükle (-mek/-mak infinitive)
428
428
  */
429
429
  function turkishLightStem(word: string): string {
430
- let w = word;
430
+ const w = word;
431
431
  // Suffixes longest-first
432
432
  if (w.endsWith('lardaki') && w.length > 7) return w.slice(0, -7);
433
433
  if (w.endsWith('lerdeki') && w.length > 7) return w.slice(0, -7);
@@ -475,7 +475,7 @@ function turkishLightStem(word: string): string {
475
475
  * সমস্যাটি → সমস্যা (-টি singular marker)
476
476
  */
477
477
  function bengaliLightStem(word: string): string {
478
- let w = word;
478
+ const w = word;
479
479
  // Plural / collective markers (longest first)
480
480
  if (w.endsWith('\u0997\u09c1\u09b2\u09cb') && w.length > 5) return w.slice(0, -4); // গুলো (-gulo plural)
481
481
  if (w.endsWith('\u0997\u09c1\u09b2\u09bf') && w.length > 5) return w.slice(0, -4); // গুলি (-guli plural)