@cachly-dev/mcp-server 0.10.46 → 0.10.48

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
@@ -54,13 +54,23 @@ import {
54
54
  McpError,
55
55
  } from '@modelcontextprotocol/sdk/types.js';
56
56
  import { Redis } from 'ioredis';
57
+ import { AsyncLocalStorage } from 'node:async_hooks';
57
58
 
58
59
  // ── Config ────────────────────────────────────────────────────────────────────
59
60
 
60
61
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
61
62
  let JWT = process.env.CACHLY_JWT ?? '';
62
63
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
63
- const CURRENT_VERSION = '0.10.46';
64
+ const CURRENT_VERSION = '0.10.47';
65
+
66
+ // ── Per-request context (multi-tenant HTTP / Smithery) ───────────────────────
67
+ // In stdio mode the module-level JWT/instance globals apply.
68
+ // In HTTP mode, the request handler runs each request in an ALS scope that
69
+ // shadows JWT + instance ID with per-user values extracted from headers.
70
+ interface ReqCtx { jwt: string; instanceId: string; }
71
+ const reqCtx = new AsyncLocalStorage<ReqCtx>();
72
+ function getJWT(): string { return reqCtx.getStore()?.jwt || JWT; }
73
+ function getDefaultInstanceId(): string { return reqCtx.getStore()?.instanceId || _defaultInstanceId; }
64
74
 
65
75
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
66
76
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
@@ -70,15 +80,18 @@ let _defaultInstanceId: string = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
70
80
  let _defaultInstanceLastAttempt = 0;
71
81
 
72
82
  async function resolveDefaultInstanceId(): Promise<string> {
83
+ // In an HTTP request scope the instance ID is fixed by the caller's header.
84
+ const scopedId = reqCtx.getStore()?.instanceId;
85
+ if (scopedId) return scopedId;
73
86
  if (_defaultInstanceId) return _defaultInstanceId;
74
- if (!JWT) return '';
87
+ if (!getJWT()) return '';
75
88
  // Cooldown: don't hammer the API on every tool call after a transient failure.
76
89
  const now = Date.now();
77
90
  if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000) return '';
78
91
  _defaultInstanceLastAttempt = now;
79
92
  try {
80
93
  const res = await fetch(`${API_URL}/api/v1/instances`, {
81
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
94
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
82
95
  signal: AbortSignal.timeout(5000),
83
96
  });
84
97
  if (!res.ok) return '';
@@ -174,7 +187,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
174
187
  try {
175
188
  const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
176
189
  method: 'POST',
177
- headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
190
+ headers: { Authorization: `Bearer ${getJWT()}`, 'Content-Type': 'application/json' },
178
191
  signal: AbortSignal.timeout(12000),
179
192
  });
180
193
  if (autoRes.ok) {
@@ -189,7 +202,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
189
202
  // Log so telemetry can detect free-tier auto-provision failures
190
203
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
191
204
  method: 'POST',
192
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${JWT}` },
205
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getJWT()}` },
193
206
  body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
194
207
  signal: AbortSignal.timeout(3000),
195
208
  }).catch(() => {});
@@ -201,7 +214,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
201
214
  if (_defaultInstanceId) {
202
215
  try {
203
216
  const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
204
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
217
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
205
218
  signal: AbortSignal.timeout(4000),
206
219
  });
207
220
  if (checkRes.ok) {
@@ -212,7 +225,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
212
225
  while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
213
226
  await new Promise(r => setTimeout(r, 3000));
214
227
  const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
215
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
228
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
216
229
  signal: AbortSignal.timeout(4000),
217
230
  }).catch(() => null);
218
231
  if (rr?.ok) {
@@ -283,7 +296,19 @@ async function getConnection(instance_id: string): Promise<Redis> {
283
296
  );
284
297
  }
285
298
 
286
- if (pool.has(instance_id)) return pool.get(instance_id)!;
299
+ if (pool.has(instance_id)) {
300
+ const cached = pool.get(instance_id)!;
301
+ // Verify the pooled connection is still alive before reusing it.
302
+ // ioredis 'error' eviction is async — a connection can be in the pool
303
+ // but already internally broken (status 'reconnecting' or 'end').
304
+ const status = (cached as any).status as string | undefined;
305
+ if (status === 'ready' || status === 'connect') {
306
+ return cached;
307
+ }
308
+ // Stale — evict and fall through to reconnect.
309
+ pool.delete(instance_id);
310
+ cached.disconnect();
311
+ }
287
312
 
288
313
  // Fetch instance, waiting up to 25 s if it is still provisioning.
289
314
  // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
@@ -301,7 +326,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
301
326
  // Telemetry: unreachable instance so the team can proactively investigate
302
327
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
303
328
  method: 'POST',
304
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
329
+ headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
305
330
  body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
306
331
  signal: AbortSignal.timeout(3000),
307
332
  }).catch(() => {});
@@ -365,14 +390,45 @@ async function getConnection(instance_id: string): Promise<Redis> {
365
390
  client.on('error', evict);
366
391
  client.on('end', evict);
367
392
 
368
- try {
369
- await client.connect();
370
- } catch (err: unknown) {
393
+ // Retry up to 3 times with 2s backoff. Covers the window between K8s PodReady
394
+ // and Valkey actually accepting connections (RDB/AOF load, ~1-3s on cold start).
395
+ let connectErr: unknown;
396
+ for (let attempt = 0; attempt < 3; attempt++) {
397
+ try {
398
+ if (attempt > 0) {
399
+ await new Promise(r => setTimeout(r, 2000 * attempt));
400
+ // Fresh client on retry — ioredis doesn't allow reconnect after explicit disconnect.
401
+ client.disconnect();
402
+ const retryClient = new Redis({
403
+ host: inst.host,
404
+ port: inst.port,
405
+ password: password || undefined,
406
+ ...(tlsEnabled ? { tls: {} } : {}),
407
+ lazyConnect: true,
408
+ enableReadyCheck: true,
409
+ connectTimeout: 8000,
410
+ retryStrategy: () => null,
411
+ });
412
+ retryClient.on('error', () => pool.delete(instance_id));
413
+ retryClient.on('end', () => pool.delete(instance_id));
414
+ await retryClient.connect();
415
+ pool.set(instance_id, retryClient);
416
+ return retryClient;
417
+ }
418
+ await client.connect();
419
+ connectErr = undefined;
420
+ break;
421
+ } catch (err) {
422
+ connectErr = err;
423
+ }
424
+ }
425
+
426
+ if (connectErr) {
371
427
  client.disconnect();
372
- const msg = err instanceof Error ? err.message : String(err);
428
+ const msg = connectErr instanceof Error ? connectErr.message : String(connectErr);
373
429
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
374
430
  method: 'POST',
375
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
431
+ headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
376
432
  body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
377
433
  signal: AbortSignal.timeout(3000),
378
434
  }).catch(() => {});
@@ -392,14 +448,14 @@ async function getConnection(instance_id: string): Promise<Redis> {
392
448
  // ── API helper ────────────────────────────────────────────────────────────────
393
449
 
394
450
  async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
395
- checkJwt(JWT);
451
+ checkJwt(getJWT());
396
452
  const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
397
453
  const res = await fetch(`${API_URL}${path}`, {
398
454
  ...options,
399
455
  signal: AbortSignal.timeout(timeoutMs),
400
456
  headers: {
401
457
  'Content-Type': 'application/json',
402
- Authorization: `Bearer ${JWT}`,
458
+ Authorization: `Bearer ${getJWT()}`,
403
459
  ...options.headers,
404
460
  },
405
461
  });
@@ -454,7 +510,7 @@ function sendFunnelEvent(event: string, extra?: Record<string, unknown>): void {
454
510
  headers: { 'Content-Type': 'application/json' },
455
511
  body: JSON.stringify({
456
512
  event, version: CURRENT_VERSION, editor: detectEditor(),
457
- ...(JWT ? { jwt: JWT } : {}),
513
+ ...(getJWT() ? { jwt: getJWT() } : {}),
458
514
  ...extra,
459
515
  }),
460
516
  signal: AbortSignal.timeout(3000),
@@ -477,7 +533,7 @@ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
477
533
 
478
534
  async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
479
535
  // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
480
- if (!JWT) {
536
+ if (!getJWT()) {
481
537
  void sendAnonymousTelemetry(name);
482
538
 
483
539
  // ── Zero-credential device flow ─────────────────────────────────────
@@ -557,14 +613,14 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
557
613
  // Delegate brain tools (learn, recall, session, etc.)
558
614
  const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
559
615
  if (brainResult !== null) {
560
- if (!_firstCallSuccessSent && JWT) {
616
+ if (!_firstCallSuccessSent && getJWT()) {
561
617
  _firstCallSuccessSent = true;
562
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
618
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
563
619
  }
564
620
  // Per-tool telemetry — fires after every successful brain tool call.
565
621
  // api_key carries the cky_live_... token so the backend can resolve tenant + increment counters.
566
- const instanceId = (args.instance_id as string | undefined) ?? _defaultInstanceId ?? '';
567
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
622
+ const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
623
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
568
624
  if (name === 'recall_best_solution') {
569
625
  // recall_best_solution → increments BrainRecallCount + triggers level-up logic
570
626
  sendFunnelEvent('recall_best_solution', telemetryExtra);
@@ -625,9 +681,9 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
625
681
  // Delegate context tools (remember/recall/list/forget)
626
682
  const contextResult = await handleContextTool(name, args, getConnection, apiFetch);
627
683
  if (contextResult !== null) {
628
- if (!_firstCallSuccessSent && JWT) {
684
+ if (!_firstCallSuccessSent && getJWT()) {
629
685
  _firstCallSuccessSent = true;
630
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
686
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
631
687
  }
632
688
  return contextResult;
633
689
  }
@@ -653,8 +709,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
653
709
  if (syndicateResult !== null) {
654
710
  // brain_predict is in syndicate handler — track telemetry here.
655
711
  if (name === 'brain_predict') {
656
- const instanceId = (args.instance_id as string | undefined) ?? _defaultInstanceId ?? '';
657
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
712
+ const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
713
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
658
714
  sendFunnelEvent('brain_predict', telemetryExtra);
659
715
  }
660
716
  return syndicateResult;
@@ -663,8 +719,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
663
719
  const fedbrainResult = await handleFedbrainTool(name, args, getConnection, apiFetch);
664
720
  if (fedbrainResult !== null) {
665
721
  // brain_from_git and brain_predict_failures are in fedbrain handler.
666
- const instanceId = (args.instance_id as string | undefined) ?? _defaultInstanceId ?? '';
667
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
722
+ const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
723
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
668
724
  if (name === 'brain_from_git') {
669
725
  sendFunnelEvent('brain_from_git', telemetryExtra);
670
726
  } else if (name === 'brain_predict_failures') {
@@ -691,7 +747,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
691
747
  }
692
748
 
693
749
  // Check JWT / auth
694
- if (!JWT) {
750
+ if (!getJWT()) {
695
751
  return [
696
752
  `📡 **cachly API Status**`,
697
753
  ``,
@@ -706,7 +762,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
706
762
  // Decode JWT claims (inspection only, no verification)
707
763
  let authInfo = '❌ invalid JWT format';
708
764
  try {
709
- const parts = JWT.split('.');
765
+ const parts = getJWT().split('.');
710
766
  if (parts.length === 3) {
711
767
  const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
712
768
  sub?: string; exp?: number; iss?: string;
@@ -739,7 +795,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
739
795
  const lines: string[] = ['\n\n🧠 **Brain Instances:**'];
740
796
  for (const inst of instances) {
741
797
  const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
742
- const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
798
+ const defaultMark = inst.id === getDefaultInstanceId() ? ' ← active' : '';
743
799
  lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
744
800
  if (inst.status === 'running' && inst.host) {
745
801
  // Try a quick ping on the cached connection
@@ -2156,7 +2212,6 @@ if (process.argv[2] === 'setup') {
2156
2212
  const CLIENT_ID = 'cachly-cli';
2157
2213
 
2158
2214
  console.log('Step 1: Sign in to cachly (free, no credit card)\n');
2159
- sendFunnelEvent('setup_auth_started');
2160
2215
 
2161
2216
  // Start device flow
2162
2217
  let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
@@ -2180,7 +2235,6 @@ if (process.argv[2] === 'setup') {
2180
2235
  console.error('Falling back: sign in at https://cachly.dev/setup-ai and paste your API token.\n');
2181
2236
  token = await ask(' Paste API token (cky_live_...): ');
2182
2237
  if (!token) { console.error('\nToken is required. Aborting.\n'); rl.close(); process.exit(1); }
2183
- sendFunnelEvent('setup_auth_completed');
2184
2238
  console.log('');
2185
2239
  deviceCode = ''; // mark as fallback so we skip polling
2186
2240
  }
@@ -2214,7 +2268,6 @@ if (process.argv[2] === 'setup') {
2214
2268
  if (tokenData.access_token) {
2215
2269
  token = tokenData.access_token;
2216
2270
  console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
2217
- sendFunnelEvent('setup_auth_completed');
2218
2271
  break;
2219
2272
  }
2220
2273
  // authorization_pending = keep polling; slow_down = increase interval
@@ -2331,7 +2384,6 @@ if (process.argv[2] === 'setup') {
2331
2384
  console.log(` (Run with --instance-id <id> to use a different one)\n`);
2332
2385
  }
2333
2386
  console.log(`✓ Instance: ${instance.name} (${instance.id.slice(0, 8)}…)\n`);
2334
- sendFunnelEvent('setup_instance_ready', { instance_id: instance.id });
2335
2387
 
2336
2388
  // ── Step 3: Detect editors ────────────────────────────────────────────────
2337
2389
  const cwd = process.cwd();
@@ -2577,7 +2629,7 @@ if (process.argv[2] === 'index') {
2577
2629
  const maxFiles = parseInt(flag('max-files') ?? '500', 10);
2578
2630
  const namespace = flag('namespace') ?? 'cachly:sem:code';
2579
2631
 
2580
- if (!instanceId || !JWT) {
2632
+ if (!instanceId || !getJWT()) {
2581
2633
  console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
2582
2634
  console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
2583
2635
  console.error(' export CACHLY_JWT=<cky_live_...>');
@@ -2619,7 +2671,7 @@ if (process.argv[2] === 'learn-git') {
2619
2671
  const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
2620
2672
  const maxCommits = parseInt(flag('max-commits') ?? '50', 10);
2621
2673
 
2622
- if (!instanceId || !JWT) {
2674
+ if (!instanceId || !getJWT()) {
2623
2675
  console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
2624
2676
  console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
2625
2677
  console.error(' export CACHLY_JWT=<cky_live_...>');
@@ -2654,7 +2706,8 @@ if (process.argv[2] === 'learn-git') {
2654
2706
  // actionable message in their editor's MCP log instead of silent failures.
2655
2707
  // Skip for CLI commands that intentionally run without credentials.
2656
2708
  const _cliNoAuthCommands = ['demo', 'share', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade'];
2657
- if (!JWT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
2709
+ // In multi-tenant HTTP mode (PORT set) we never need a startup JWT — per-request auth.
2710
+ if (!JWT && !process.env.PORT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
2658
2711
  // No args + no JWT in a non-TTY context = running as MCP server without credentials.
2659
2712
  // Print actionable setup banner to stdout so it's captured by callers/tests.
2660
2713
  const banner =
@@ -2727,12 +2780,76 @@ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
2727
2780
 
2728
2781
  const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
2729
2782
 
2783
+ // ── Per-request MCP Server factory (HTTP / Smithery stateless mode) ──────────
2784
+ // SDK ≥1.9 forbids reusing a stateless transport across requests.
2785
+ // Solution: create a fresh Server+Transport per /mcp request.
2786
+ // The module-level `server` below is used for stdio mode only.
2787
+ function createHttpMcpServer(): Server {
2788
+ const srv = new Server(
2789
+ { name: 'cachly-mcp', version: CURRENT_VERSION },
2790
+ { capabilities: { tools: {} } }
2791
+ );
2792
+ srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
2793
+ srv.setRequestHandler(CallToolRequestSchema, async (request) => {
2794
+ const { name, arguments: args } = request.params;
2795
+ try {
2796
+ const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
2797
+ return { content: [{ type: 'text', text }] };
2798
+ } catch (err) {
2799
+ if (err instanceof McpError) throw err;
2800
+ throw new McpError(ErrorCode.InternalError, (err as Error).message);
2801
+ }
2802
+ });
2803
+ return srv;
2804
+ }
2805
+
2730
2806
  if (httpPort) {
2731
2807
  // ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
2732
2808
  // Used for Smithery URL deployment: PORT=3000 node dist/index.js
2809
+ // Multi-tenant: per-request JWT + instance ID resolved via:
2810
+ // 1. Authorization: Bearer <jwt> + X-Cachly-Instance-Id: <uuid>
2811
+ // 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
2812
+ // Falls back to module-level env-derived values when neither is provided.
2733
2813
  const { createServer } = await import('node:http');
2734
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
2735
- await server.connect(transport);
2814
+
2815
+ function extractReqCtx(req: import('node:http').IncomingMessage, url: URL): ReqCtx {
2816
+ let jwtVal = '';
2817
+ let instId = '';
2818
+ const auth = req.headers['authorization'];
2819
+ if (typeof auth === 'string' && auth.trim()) {
2820
+ const a = auth.trim();
2821
+ jwtVal = a.toLowerCase().startsWith('bearer ') ? a.slice(7).trim() : a;
2822
+ }
2823
+ // Fallback: raw JWT in custom header (Smithery "header" transport without Bearer prefix)
2824
+ if (!jwtVal) {
2825
+ const rawJwt = req.headers['cachly_jwt'] ?? req.headers['cachly-jwt'] ?? req.headers['x-cachly-jwt'] ?? req.headers['x-api-key'];
2826
+ if (typeof rawJwt === 'string') jwtVal = rawJwt.trim();
2827
+ }
2828
+ const hdrInst = req.headers['x-cachly-instance-id'];
2829
+ if (typeof hdrInst === 'string') instId = hdrInst.trim();
2830
+ // Smithery passes user config as a base64url-encoded JSON blob in ?config=
2831
+ const cfgParam = url.searchParams.get('config');
2832
+ if (cfgParam && (!jwtVal || !instId)) {
2833
+ try {
2834
+ const b64 = cfgParam.replace(/-/g, '+').replace(/_/g, '/');
2835
+ const padded = b64 + '='.repeat((4 - b64.length % 4) % 4);
2836
+ const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
2837
+ if (!jwtVal && typeof json.CACHLY_JWT === 'string') jwtVal = json.CACHLY_JWT;
2838
+ if (!instId && typeof json.CACHLY_BRAIN_INSTANCE_ID === 'string') instId = json.CACHLY_BRAIN_INSTANCE_ID;
2839
+ } catch { /* malformed config — ignore, fall back to globals */ }
2840
+ }
2841
+ // Direct query params (Smithery "query" transport, manual clients, etc.)
2842
+ if (!jwtVal) {
2843
+ const q = url.searchParams.get('CACHLY_JWT') ?? url.searchParams.get('cachly_jwt') ?? url.searchParams.get('api_key');
2844
+ if (q) jwtVal = q.trim();
2845
+ }
2846
+ if (!instId) {
2847
+ const q = url.searchParams.get('CACHLY_BRAIN_INSTANCE_ID') ?? url.searchParams.get('cachly_brain_instance_id') ?? url.searchParams.get('instance_id');
2848
+ if (q) instId = q.trim();
2849
+ }
2850
+ return { jwt: jwtVal, instanceId: instId };
2851
+ }
2852
+
2736
2853
  const httpServer = createServer((req, res) => {
2737
2854
  const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
2738
2855
  if (url.pathname === '/health') {
@@ -2740,6 +2857,8 @@ if (httpPort) {
2740
2857
  res.end(JSON.stringify({
2741
2858
  status: 'ok',
2742
2859
  version: CURRENT_VERSION,
2860
+ transport: 'streamable-http',
2861
+ multiTenant: true,
2743
2862
  zeroResults: {
2744
2863
  total: zeroResultsTotal,
2745
2864
  last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
@@ -2747,8 +2866,52 @@ if (httpPort) {
2747
2866
  }));
2748
2867
  return;
2749
2868
  }
2869
+ // Static server card — lets Smithery skip live scanning (avoids auth complexity).
2870
+ if (url.pathname === '/.well-known/mcp/server-card.json') {
2871
+ const card = {
2872
+ name: 'cachly AI Brain',
2873
+ version: CURRENT_VERSION,
2874
+ description: 'Persistent AI memory for Claude Code, Cursor, Copilot, Windsurf & friends. Remembers every lesson, fix, and architecture decision forever.',
2875
+ transport: { type: 'streamable-http', url: 'https://mcp.cachly.dev/mcp' },
2876
+ capabilities: { tools: {} },
2877
+ };
2878
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
2879
+ res.end(JSON.stringify(card));
2880
+ return;
2881
+ }
2750
2882
  if (url.pathname === '/mcp' || url.pathname === '/') {
2751
- transport.handleRequest(req, res);
2883
+ // Handle CORS preflight
2884
+ if (req.method === 'OPTIONS') {
2885
+ res.writeHead(204, {
2886
+ 'Access-Control-Allow-Origin': '*',
2887
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
2888
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, X-Cachly-Instance-Id, cachly_jwt, cachly-jwt, x-cachly-jwt, x-api-key',
2889
+ });
2890
+ res.end();
2891
+ return;
2892
+ }
2893
+ const ctx = extractReqCtx(req, url);
2894
+ // Create a fresh Server+Transport for each request (stateless multi-tenant mode).
2895
+ // SDK ≥1.9 throws if a stateless transport is reused across requests.
2896
+ const reqServer = createHttpMcpServer();
2897
+ const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
2898
+ reqServer.connect(reqTransport).then(() => {
2899
+ reqCtx.run(ctx, () => {
2900
+ reqTransport.handleRequest(req, res).catch((err) => {
2901
+ process.stderr.write(`cachly-mcp HTTP handler error: ${(err as Error).message}\n`);
2902
+ if (!res.headersSent) {
2903
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2904
+ res.end(JSON.stringify({ error: 'internal_error' }));
2905
+ }
2906
+ });
2907
+ });
2908
+ }).catch((err) => {
2909
+ process.stderr.write(`cachly-mcp connect error: ${(err as Error).message}\n`);
2910
+ if (!res.headersSent) {
2911
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2912
+ res.end(JSON.stringify({ error: 'internal_error' }));
2913
+ }
2914
+ });
2752
2915
  return;
2753
2916
  }
2754
2917
  res.writeHead(404);
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)