@cachly-dev/mcp-server 0.10.47 → 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
@@ -241,8 +241,19 @@ async function getConnection(instance_id) {
241
241
  '• Pass instance_id explicitly to the tool, or\n' +
242
242
  '• Run `list_instances` to see your available instances.');
243
243
  }
244
- if (pool.has(instance_id))
245
- 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
+ }
246
257
  // Fetch instance, waiting up to 25 s if it is still provisioning.
247
258
  // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
248
259
  // all happen in quick succession and the instance isn't running yet.
@@ -319,12 +330,42 @@ async function getConnection(instance_id) {
319
330
  const evict = () => pool.delete(instance_id);
320
331
  client.on('error', evict);
321
332
  client.on('end', evict);
322
- try {
323
- 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
+ }
324
365
  }
325
- catch (err) {
366
+ if (connectErr) {
326
367
  client.disconnect();
327
- const msg = err instanceof Error ? err.message : String(err);
368
+ const msg = connectErr instanceof Error ? connectErr.message : String(connectErr);
328
369
  void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
329
370
  method: 'POST',
330
371
  headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
@@ -2603,6 +2644,27 @@ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
2603
2644
  })();
2604
2645
  }
2605
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
+ }
2606
2668
  if (httpPort) {
2607
2669
  // ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
2608
2670
  // Used for Smithery URL deployment: PORT=3000 node dist/index.js
@@ -2611,14 +2673,19 @@ if (httpPort) {
2611
2673
  // 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
2612
2674
  // Falls back to module-level env-derived values when neither is provided.
2613
2675
  const { createServer } = await import('node:http');
2614
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
2615
- await server.connect(transport);
2616
2676
  function extractReqCtx(req, url) {
2617
2677
  let jwtVal = '';
2618
2678
  let instId = '';
2619
2679
  const auth = req.headers['authorization'];
2620
- if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) {
2621
- jwtVal = auth.slice(7).trim();
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();
2622
2689
  }
2623
2690
  const hdrInst = req.headers['x-cachly-instance-id'];
2624
2691
  if (typeof hdrInst === 'string')
@@ -2637,6 +2704,17 @@ if (httpPort) {
2637
2704
  }
2638
2705
  catch { /* malformed config — ignore, fall back to globals */ }
2639
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
+ }
2640
2718
  return { jwt: jwtVal, instanceId: instId };
2641
2719
  }
2642
2720
  const httpServer = createServer((req, res) => {
@@ -2655,16 +2733,51 @@ if (httpPort) {
2655
2733
  }));
2656
2734
  return;
2657
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
+ }
2658
2749
  if (url.pathname === '/mcp' || url.pathname === '/') {
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
+ }
2659
2760
  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
- }
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
+ });
2667
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
+ }
2668
2781
  });
2669
2782
  return;
2670
2783
  }