@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/dist/index.js CHANGED
@@ -45,11 +45,15 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
45
45
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
46
46
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
47
47
  import { Redis } from 'ioredis';
48
+ import { AsyncLocalStorage } from 'node:async_hooks';
48
49
  // ── Config ────────────────────────────────────────────────────────────────────
49
50
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
50
51
  let JWT = process.env.CACHLY_JWT ?? '';
51
52
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
52
- const CURRENT_VERSION = '0.10.46';
53
+ const CURRENT_VERSION = '0.10.47';
54
+ const reqCtx = new AsyncLocalStorage();
55
+ function getJWT() { return reqCtx.getStore()?.jwt || JWT; }
56
+ function getDefaultInstanceId() { return reqCtx.getStore()?.instanceId || _defaultInstanceId; }
53
57
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
54
58
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
55
59
  // When neither is set, we auto-fetch the first running instance once per process.
@@ -57,9 +61,13 @@ let _defaultInstanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
57
61
  // Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
58
62
  let _defaultInstanceLastAttempt = 0;
59
63
  async function resolveDefaultInstanceId() {
64
+ // In an HTTP request scope the instance ID is fixed by the caller's header.
65
+ const scopedId = reqCtx.getStore()?.instanceId;
66
+ if (scopedId)
67
+ return scopedId;
60
68
  if (_defaultInstanceId)
61
69
  return _defaultInstanceId;
62
- if (!JWT)
70
+ if (!getJWT())
63
71
  return '';
64
72
  // Cooldown: don't hammer the API on every tool call after a transient failure.
65
73
  const now = Date.now();
@@ -68,7 +76,7 @@ async function resolveDefaultInstanceId() {
68
76
  _defaultInstanceLastAttempt = now;
69
77
  try {
70
78
  const res = await fetch(`${API_URL}/api/v1/instances`, {
71
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
79
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
72
80
  signal: AbortSignal.timeout(5000),
73
81
  });
74
82
  if (!res.ok)
@@ -154,7 +162,7 @@ async function pollDeviceFlow(flow) {
154
162
  try {
155
163
  const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
156
164
  method: 'POST',
157
- headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
165
+ headers: { Authorization: `Bearer ${getJWT()}`, 'Content-Type': 'application/json' },
158
166
  signal: AbortSignal.timeout(12000),
159
167
  });
160
168
  if (autoRes.ok) {
@@ -167,7 +175,7 @@ async function pollDeviceFlow(flow) {
167
175
  // Log so telemetry can detect free-tier auto-provision failures
168
176
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
169
177
  method: 'POST',
170
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${JWT}` },
178
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getJWT()}` },
171
179
  body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
172
180
  signal: AbortSignal.timeout(3000),
173
181
  }).catch(() => { });
@@ -180,7 +188,7 @@ async function pollDeviceFlow(flow) {
180
188
  if (_defaultInstanceId) {
181
189
  try {
182
190
  const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
183
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
191
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
184
192
  signal: AbortSignal.timeout(4000),
185
193
  });
186
194
  if (checkRes.ok) {
@@ -191,7 +199,7 @@ async function pollDeviceFlow(flow) {
191
199
  while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
192
200
  await new Promise(r => setTimeout(r, 3000));
193
201
  const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
194
- headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
202
+ headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
195
203
  signal: AbortSignal.timeout(4000),
196
204
  }).catch(() => null);
197
205
  if (rr?.ok) {
@@ -233,8 +241,19 @@ async function getConnection(instance_id) {
233
241
  '• Pass instance_id explicitly to the tool, or\n' +
234
242
  '• Run `list_instances` to see your available instances.');
235
243
  }
236
- if (pool.has(instance_id))
237
- return pool.get(instance_id);
244
+ if (pool.has(instance_id)) {
245
+ const cached = pool.get(instance_id);
246
+ // Verify the pooled connection is still alive before reusing it.
247
+ // ioredis 'error' eviction is async — a connection can be in the pool
248
+ // but already internally broken (status 'reconnecting' or 'end').
249
+ const status = cached.status;
250
+ if (status === 'ready' || status === 'connect') {
251
+ return cached;
252
+ }
253
+ // Stale — evict and fall through to reconnect.
254
+ pool.delete(instance_id);
255
+ cached.disconnect();
256
+ }
238
257
  // Fetch instance, waiting up to 25 s if it is still provisioning.
239
258
  // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
240
259
  // all happen in quick succession and the instance isn't running yet.
@@ -250,7 +269,7 @@ async function getConnection(instance_id) {
250
269
  // Telemetry: unreachable instance so the team can proactively investigate
251
270
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
252
271
  method: 'POST',
253
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
272
+ headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
254
273
  body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
255
274
  signal: AbortSignal.timeout(3000),
256
275
  }).catch(() => { });
@@ -311,15 +330,45 @@ async function getConnection(instance_id) {
311
330
  const evict = () => pool.delete(instance_id);
312
331
  client.on('error', evict);
313
332
  client.on('end', evict);
314
- try {
315
- await client.connect();
333
+ // Retry up to 3 times with 2s backoff. Covers the window between K8s PodReady
334
+ // and Valkey actually accepting connections (RDB/AOF load, ~1-3s on cold start).
335
+ let connectErr;
336
+ for (let attempt = 0; attempt < 3; attempt++) {
337
+ try {
338
+ if (attempt > 0) {
339
+ await new Promise(r => setTimeout(r, 2000 * attempt));
340
+ // Fresh client on retry — ioredis doesn't allow reconnect after explicit disconnect.
341
+ client.disconnect();
342
+ const retryClient = new Redis({
343
+ host: inst.host,
344
+ port: inst.port,
345
+ password: password || undefined,
346
+ ...(tlsEnabled ? { tls: {} } : {}),
347
+ lazyConnect: true,
348
+ enableReadyCheck: true,
349
+ connectTimeout: 8000,
350
+ retryStrategy: () => null,
351
+ });
352
+ retryClient.on('error', () => pool.delete(instance_id));
353
+ retryClient.on('end', () => pool.delete(instance_id));
354
+ await retryClient.connect();
355
+ pool.set(instance_id, retryClient);
356
+ return retryClient;
357
+ }
358
+ await client.connect();
359
+ connectErr = undefined;
360
+ break;
361
+ }
362
+ catch (err) {
363
+ connectErr = err;
364
+ }
316
365
  }
317
- catch (err) {
366
+ if (connectErr) {
318
367
  client.disconnect();
319
- const msg = err instanceof Error ? err.message : String(err);
368
+ const msg = connectErr instanceof Error ? connectErr.message : String(connectErr);
320
369
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
321
370
  method: 'POST',
322
- headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
371
+ headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
323
372
  body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
324
373
  signal: AbortSignal.timeout(3000),
325
374
  }).catch(() => { });
@@ -333,14 +382,14 @@ async function getConnection(instance_id) {
333
382
  }
334
383
  // ── API helper ────────────────────────────────────────────────────────────────
335
384
  async function apiFetch(path, options = {}) {
336
- checkJwt(JWT);
385
+ checkJwt(getJWT());
337
386
  const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
338
387
  const res = await fetch(`${API_URL}${path}`, {
339
388
  ...options,
340
389
  signal: AbortSignal.timeout(timeoutMs),
341
390
  headers: {
342
391
  'Content-Type': 'application/json',
343
- Authorization: `Bearer ${JWT}`,
392
+ Authorization: `Bearer ${getJWT()}`,
344
393
  ...options.headers,
345
394
  },
346
395
  });
@@ -384,7 +433,7 @@ function sendFunnelEvent(event, extra) {
384
433
  headers: { 'Content-Type': 'application/json' },
385
434
  body: JSON.stringify({
386
435
  event, version: CURRENT_VERSION, editor: detectEditor(),
387
- ...(JWT ? { jwt: JWT } : {}),
436
+ ...(getJWT() ? { jwt: getJWT() } : {}),
388
437
  ...extra,
389
438
  }),
390
439
  signal: AbortSignal.timeout(3000),
@@ -408,7 +457,7 @@ async function sendAnonymousTelemetry(toolName) {
408
457
  }
409
458
  async function handleTool(name, args) {
410
459
  // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
411
- if (!JWT) {
460
+ if (!getJWT()) {
412
461
  void sendAnonymousTelemetry(name);
413
462
  // ── Zero-credential device flow ─────────────────────────────────────
414
463
  // 1st call: start device flow, return code + URL
@@ -485,14 +534,14 @@ async function handleTool(name, args) {
485
534
  // Delegate brain tools (learn, recall, session, etc.)
486
535
  const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
487
536
  if (brainResult !== null) {
488
- if (!_firstCallSuccessSent && JWT) {
537
+ if (!_firstCallSuccessSent && getJWT()) {
489
538
  _firstCallSuccessSent = true;
490
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
539
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
491
540
  }
492
541
  // Per-tool telemetry — fires after every successful brain tool call.
493
542
  // api_key carries the cky_live_... token so the backend can resolve tenant + increment counters.
494
- const instanceId = args.instance_id ?? _defaultInstanceId ?? '';
495
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
543
+ const instanceId = args.instance_id ?? getDefaultInstanceId() ?? '';
544
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
496
545
  if (name === 'recall_best_solution') {
497
546
  // recall_best_solution → increments BrainRecallCount + triggers level-up logic
498
547
  sendFunnelEvent('recall_best_solution', telemetryExtra);
@@ -557,9 +606,9 @@ async function handleTool(name, args) {
557
606
  // Delegate context tools (remember/recall/list/forget)
558
607
  const contextResult = await handleContextTool(name, args, getConnection, apiFetch);
559
608
  if (contextResult !== null) {
560
- if (!_firstCallSuccessSent && JWT) {
609
+ if (!_firstCallSuccessSent && getJWT()) {
561
610
  _firstCallSuccessSent = true;
562
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
611
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
563
612
  }
564
613
  return contextResult;
565
614
  }
@@ -584,8 +633,8 @@ async function handleTool(name, args) {
584
633
  if (syndicateResult !== null) {
585
634
  // brain_predict is in syndicate handler — track telemetry here.
586
635
  if (name === 'brain_predict') {
587
- const instanceId = args.instance_id ?? _defaultInstanceId ?? '';
588
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
636
+ const instanceId = args.instance_id ?? getDefaultInstanceId() ?? '';
637
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
589
638
  sendFunnelEvent('brain_predict', telemetryExtra);
590
639
  }
591
640
  return syndicateResult;
@@ -593,8 +642,8 @@ async function handleTool(name, args) {
593
642
  const fedbrainResult = await handleFedbrainTool(name, args, getConnection, apiFetch);
594
643
  if (fedbrainResult !== null) {
595
644
  // brain_from_git and brain_predict_failures are in fedbrain handler.
596
- const instanceId = args.instance_id ?? _defaultInstanceId ?? '';
597
- const telemetryExtra = JWT ? { api_key: JWT, instance_id: instanceId } : { instance_id: instanceId };
645
+ const instanceId = args.instance_id ?? getDefaultInstanceId() ?? '';
646
+ const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
598
647
  if (name === 'brain_from_git') {
599
648
  sendFunnelEvent('brain_from_git', telemetryExtra);
600
649
  }
@@ -622,7 +671,7 @@ async function handleTool(name, args) {
622
671
  healthStatus = `unreachable: ${e.message}`;
623
672
  }
624
673
  // Check JWT / auth
625
- if (!JWT) {
674
+ if (!getJWT()) {
626
675
  return [
627
676
  `📡 **cachly API Status**`,
628
677
  ``,
@@ -636,7 +685,7 @@ async function handleTool(name, args) {
636
685
  // Decode JWT claims (inspection only, no verification)
637
686
  let authInfo = '❌ invalid JWT format';
638
687
  try {
639
- const parts = JWT.split('.');
688
+ const parts = getJWT().split('.');
640
689
  if (parts.length === 3) {
641
690
  const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
642
691
  const sub = payload.sub ?? '(unknown)';
@@ -668,7 +717,7 @@ async function handleTool(name, args) {
668
717
  const lines = ['\n\n🧠 **Brain Instances:**'];
669
718
  for (const inst of instances) {
670
719
  const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
671
- const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
720
+ const defaultMark = inst.id === getDefaultInstanceId() ? ' ← active' : '';
672
721
  lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
673
722
  if (inst.status === 'running' && inst.host) {
674
723
  // Try a quick ping on the cached connection
@@ -2022,7 +2071,6 @@ if (process.argv[2] === 'setup') {
2022
2071
  const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
2023
2072
  const CLIENT_ID = 'cachly-cli';
2024
2073
  console.log('Step 1: Sign in to cachly (free, no credit card)\n');
2025
- sendFunnelEvent('setup_auth_started');
2026
2074
  // Start device flow
2027
2075
  let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
2028
2076
  try {
@@ -2048,7 +2096,6 @@ if (process.argv[2] === 'setup') {
2048
2096
  rl.close();
2049
2097
  process.exit(1);
2050
2098
  }
2051
- sendFunnelEvent('setup_auth_completed');
2052
2099
  console.log('');
2053
2100
  deviceCode = ''; // mark as fallback so we skip polling
2054
2101
  }
@@ -2081,7 +2128,6 @@ if (process.argv[2] === 'setup') {
2081
2128
  if (tokenData.access_token) {
2082
2129
  token = tokenData.access_token;
2083
2130
  console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
2084
- sendFunnelEvent('setup_auth_completed');
2085
2131
  break;
2086
2132
  }
2087
2133
  // authorization_pending = keep polling; slow_down = increase interval
@@ -2216,7 +2262,6 @@ if (process.argv[2] === 'setup') {
2216
2262
  console.log(` (Run with --instance-id <id> to use a different one)\n`);
2217
2263
  }
2218
2264
  console.log(`✓ Instance: ${instance.name} (${instance.id.slice(0, 8)}…)\n`);
2219
- sendFunnelEvent('setup_instance_ready', { instance_id: instance.id });
2220
2265
  // ── Step 3: Detect editors ────────────────────────────────────────────────
2221
2266
  const cwd = process.cwd();
2222
2267
  const detected = [];
@@ -2465,7 +2510,7 @@ if (process.argv[2] === 'index') {
2465
2510
  const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
2466
2511
  const maxFiles = parseInt(flag('max-files') ?? '500', 10);
2467
2512
  const namespace = flag('namespace') ?? 'cachly:sem:code';
2468
- if (!instanceId || !JWT) {
2513
+ if (!instanceId || !getJWT()) {
2469
2514
  console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
2470
2515
  console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
2471
2516
  console.error(' export CACHLY_JWT=<cky_live_...>');
@@ -2502,7 +2547,7 @@ if (process.argv[2] === 'learn-git') {
2502
2547
  const repoDir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
2503
2548
  const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
2504
2549
  const maxCommits = parseInt(flag('max-commits') ?? '50', 10);
2505
- if (!instanceId || !JWT) {
2550
+ if (!instanceId || !getJWT()) {
2506
2551
  console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
2507
2552
  console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
2508
2553
  console.error(' export CACHLY_JWT=<cky_live_...>');
@@ -2534,7 +2579,8 @@ if (process.argv[2] === 'learn-git') {
2534
2579
  // actionable message in their editor's MCP log instead of silent failures.
2535
2580
  // Skip for CLI commands that intentionally run without credentials.
2536
2581
  const _cliNoAuthCommands = ['demo', 'share', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade'];
2537
- if (!JWT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
2582
+ // In multi-tenant HTTP mode (PORT set) we never need a startup JWT — per-request auth.
2583
+ if (!JWT && !process.env.PORT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
2538
2584
  // No args + no JWT in a non-TTY context = running as MCP server without credentials.
2539
2585
  // Print actionable setup banner to stdout so it's captured by callers/tests.
2540
2586
  const banner = '\n' +
@@ -2598,12 +2644,79 @@ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
2598
2644
  })();
2599
2645
  }
2600
2646
  const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
2647
+ // ── Per-request MCP Server factory (HTTP / Smithery stateless mode) ──────────
2648
+ // SDK ≥1.9 forbids reusing a stateless transport across requests.
2649
+ // Solution: create a fresh Server+Transport per /mcp request.
2650
+ // The module-level `server` below is used for stdio mode only.
2651
+ function createHttpMcpServer() {
2652
+ const srv = new Server({ name: 'cachly-mcp', version: CURRENT_VERSION }, { capabilities: { tools: {} } });
2653
+ srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
2654
+ srv.setRequestHandler(CallToolRequestSchema, async (request) => {
2655
+ const { name, arguments: args } = request.params;
2656
+ try {
2657
+ const text = await handleTool(name, (args ?? {}));
2658
+ return { content: [{ type: 'text', text }] };
2659
+ }
2660
+ catch (err) {
2661
+ if (err instanceof McpError)
2662
+ throw err;
2663
+ throw new McpError(ErrorCode.InternalError, err.message);
2664
+ }
2665
+ });
2666
+ return srv;
2667
+ }
2601
2668
  if (httpPort) {
2602
2669
  // ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
2603
2670
  // Used for Smithery URL deployment: PORT=3000 node dist/index.js
2671
+ // Multi-tenant: per-request JWT + instance ID resolved via:
2672
+ // 1. Authorization: Bearer <jwt> + X-Cachly-Instance-Id: <uuid>
2673
+ // 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
2674
+ // Falls back to module-level env-derived values when neither is provided.
2604
2675
  const { createServer } = await import('node:http');
2605
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
2606
- await server.connect(transport);
2676
+ function extractReqCtx(req, url) {
2677
+ let jwtVal = '';
2678
+ let instId = '';
2679
+ const auth = req.headers['authorization'];
2680
+ if (typeof auth === 'string' && auth.trim()) {
2681
+ const a = auth.trim();
2682
+ jwtVal = a.toLowerCase().startsWith('bearer ') ? a.slice(7).trim() : a;
2683
+ }
2684
+ // Fallback: raw JWT in custom header (Smithery "header" transport without Bearer prefix)
2685
+ if (!jwtVal) {
2686
+ const rawJwt = req.headers['cachly_jwt'] ?? req.headers['cachly-jwt'] ?? req.headers['x-cachly-jwt'] ?? req.headers['x-api-key'];
2687
+ if (typeof rawJwt === 'string')
2688
+ jwtVal = rawJwt.trim();
2689
+ }
2690
+ const hdrInst = req.headers['x-cachly-instance-id'];
2691
+ if (typeof hdrInst === 'string')
2692
+ instId = hdrInst.trim();
2693
+ // Smithery passes user config as a base64url-encoded JSON blob in ?config=
2694
+ const cfgParam = url.searchParams.get('config');
2695
+ if (cfgParam && (!jwtVal || !instId)) {
2696
+ try {
2697
+ const b64 = cfgParam.replace(/-/g, '+').replace(/_/g, '/');
2698
+ const padded = b64 + '='.repeat((4 - b64.length % 4) % 4);
2699
+ const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
2700
+ if (!jwtVal && typeof json.CACHLY_JWT === 'string')
2701
+ jwtVal = json.CACHLY_JWT;
2702
+ if (!instId && typeof json.CACHLY_BRAIN_INSTANCE_ID === 'string')
2703
+ instId = json.CACHLY_BRAIN_INSTANCE_ID;
2704
+ }
2705
+ catch { /* malformed config — ignore, fall back to globals */ }
2706
+ }
2707
+ // Direct query params (Smithery "query" transport, manual clients, etc.)
2708
+ if (!jwtVal) {
2709
+ const q = url.searchParams.get('CACHLY_JWT') ?? url.searchParams.get('cachly_jwt') ?? url.searchParams.get('api_key');
2710
+ if (q)
2711
+ jwtVal = q.trim();
2712
+ }
2713
+ if (!instId) {
2714
+ const q = url.searchParams.get('CACHLY_BRAIN_INSTANCE_ID') ?? url.searchParams.get('cachly_brain_instance_id') ?? url.searchParams.get('instance_id');
2715
+ if (q)
2716
+ instId = q.trim();
2717
+ }
2718
+ return { jwt: jwtVal, instanceId: instId };
2719
+ }
2607
2720
  const httpServer = createServer((req, res) => {
2608
2721
  const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
2609
2722
  if (url.pathname === '/health') {
@@ -2611,6 +2724,8 @@ if (httpPort) {
2611
2724
  res.end(JSON.stringify({
2612
2725
  status: 'ok',
2613
2726
  version: CURRENT_VERSION,
2727
+ transport: 'streamable-http',
2728
+ multiTenant: true,
2614
2729
  zeroResults: {
2615
2730
  total: zeroResultsTotal,
2616
2731
  last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
@@ -2618,8 +2733,52 @@ if (httpPort) {
2618
2733
  }));
2619
2734
  return;
2620
2735
  }
2736
+ // Static server card — lets Smithery skip live scanning (avoids auth complexity).
2737
+ if (url.pathname === '/.well-known/mcp/server-card.json') {
2738
+ const card = {
2739
+ name: 'cachly AI Brain',
2740
+ version: CURRENT_VERSION,
2741
+ description: 'Persistent AI memory for Claude Code, Cursor, Copilot, Windsurf & friends. Remembers every lesson, fix, and architecture decision forever.',
2742
+ transport: { type: 'streamable-http', url: 'https://mcp.cachly.dev/mcp' },
2743
+ capabilities: { tools: {} },
2744
+ };
2745
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
2746
+ res.end(JSON.stringify(card));
2747
+ return;
2748
+ }
2621
2749
  if (url.pathname === '/mcp' || url.pathname === '/') {
2622
- transport.handleRequest(req, res);
2750
+ // Handle CORS preflight
2751
+ if (req.method === 'OPTIONS') {
2752
+ res.writeHead(204, {
2753
+ 'Access-Control-Allow-Origin': '*',
2754
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
2755
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, X-Cachly-Instance-Id, cachly_jwt, cachly-jwt, x-cachly-jwt, x-api-key',
2756
+ });
2757
+ res.end();
2758
+ return;
2759
+ }
2760
+ const ctx = extractReqCtx(req, url);
2761
+ // Create a fresh Server+Transport for each request (stateless multi-tenant mode).
2762
+ // SDK ≥1.9 throws if a stateless transport is reused across requests.
2763
+ const reqServer = createHttpMcpServer();
2764
+ const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
2765
+ reqServer.connect(reqTransport).then(() => {
2766
+ reqCtx.run(ctx, () => {
2767
+ reqTransport.handleRequest(req, res).catch((err) => {
2768
+ process.stderr.write(`cachly-mcp HTTP handler error: ${err.message}\n`);
2769
+ if (!res.headersSent) {
2770
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2771
+ res.end(JSON.stringify({ error: 'internal_error' }));
2772
+ }
2773
+ });
2774
+ });
2775
+ }).catch((err) => {
2776
+ process.stderr.write(`cachly-mcp connect error: ${err.message}\n`);
2777
+ if (!res.headersSent) {
2778
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2779
+ res.end(JSON.stringify({ error: 'internal_error' }));
2780
+ }
2781
+ });
2623
2782
  return;
2624
2783
  }
2625
2784
  res.writeHead(404);