@cachly-dev/mcp-server 0.9.24 → 0.10.0

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
@@ -52,19 +52,22 @@ import { Redis } from 'ioredis';
52
52
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
53
53
  let JWT = process.env.CACHLY_JWT ?? '';
54
54
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
55
- const CURRENT_VERSION = '0.9.17';
55
+ const CURRENT_VERSION = '0.10.0';
56
56
 
57
57
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
58
58
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
59
59
  // When neither is set, we auto-fetch the first running instance once per process.
60
60
  let _defaultInstanceId: string = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
61
- let _defaultInstanceFetched = false;
61
+ // Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
62
+ let _defaultInstanceLastAttempt = 0;
62
63
 
63
64
  async function resolveDefaultInstanceId(): Promise<string> {
64
65
  if (_defaultInstanceId) return _defaultInstanceId;
65
- if (_defaultInstanceFetched) return '';
66
- _defaultInstanceFetched = true;
67
66
  if (!JWT) return '';
67
+ // Cooldown: don't hammer the API on every tool call after a transient failure.
68
+ const now = Date.now();
69
+ if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000) return '';
70
+ _defaultInstanceLastAttempt = now;
68
71
  try {
69
72
  const res = await fetch(`${API_URL}/api/v1/instances`, {
70
73
  headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
@@ -72,9 +75,15 @@ async function resolveDefaultInstanceId(): Promise<string> {
72
75
  });
73
76
  if (!res.ok) return '';
74
77
  const data = await res.json() as { data?: Array<{ id: string; status: string }> };
75
- const running = (data?.data ?? []).filter(i => i.status === 'running');
76
- if (running.length > 0) { _defaultInstanceId = running[0].id; return _defaultInstanceId; }
77
- } catch { /* non-fatal — caller will surface missing instance_id error */ }
78
+ const instances = (data?.data ?? []) as Array<{ id: string; status: string }>;
79
+ // Prefer running, fall back to provisioning so the ID is resolved even during startup.
80
+ const best = instances.find(i => i.status === 'running') ?? instances.find(i => i.status === 'provisioning');
81
+ if (best) {
82
+ _defaultInstanceId = best.id;
83
+ _defaultInstanceLastAttempt = 0;
84
+ return _defaultInstanceId;
85
+ }
86
+ } catch { /* transient error — will retry after cooldown */ }
78
87
  return '';
79
88
  }
80
89
 
@@ -149,24 +158,55 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
149
158
  JWT = apiKey;
150
159
  setEmbedJwt(apiKey); // keep embeddings.ts in sync
151
160
  _deviceFlow = null;
152
- // Auto-provision instance
153
- _defaultInstanceFetched = false;
161
+ // Auto-provision: find or create the user's brain instance.
162
+ _defaultInstanceLastAttempt = 0;
154
163
  await resolveDefaultInstanceId();
155
164
  if (!_defaultInstanceId) {
156
- // Try auto-provision
157
165
  try {
158
166
  const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
159
167
  method: 'POST',
160
168
  headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
161
- signal: AbortSignal.timeout(10000),
169
+ signal: AbortSignal.timeout(12000),
162
170
  });
163
171
  if (autoRes.ok) {
164
- const body = await autoRes.json() as { instance?: { id: string }; instance_id?: string };
172
+ const body = await autoRes.json() as {
173
+ instance?: { id: string; status?: string };
174
+ instance_id?: string;
175
+ status?: string;
176
+ };
165
177
  const id = body.instance?.id ?? body.instance_id;
166
178
  if (id) _defaultInstanceId = id;
167
179
  }
168
180
  } catch { /* non-fatal */ }
169
181
  }
182
+ // Give a provisioning instance a head-start so getConnection's wait is shorter.
183
+ // The instance is almost always ready by the time the user's tool re-enters.
184
+ if (_defaultInstanceId) {
185
+ try {
186
+ const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
187
+ headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
188
+ signal: AbortSignal.timeout(4000),
189
+ });
190
+ if (checkRes.ok) {
191
+ const checkInst = await checkRes.json() as { status?: string };
192
+ if (checkInst.status === 'provisioning') {
193
+ // Wait up to 20s here so the re-entered tool call lands on a running instance.
194
+ const pDeadline = Date.now() + 20_000;
195
+ while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
196
+ await new Promise(r => setTimeout(r, 3000));
197
+ const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
198
+ headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
199
+ signal: AbortSignal.timeout(4000),
200
+ }).catch(() => null);
201
+ if (rr?.ok) {
202
+ const ri = await rr.json() as { status?: string };
203
+ (checkInst as { status?: string }).status = ri.status;
204
+ } else { break; }
205
+ }
206
+ }
207
+ }
208
+ } catch { /* non-fatal — getConnection will handle the wait if still provisioning */ }
209
+ }
170
210
  return 'done';
171
211
  }
172
212
  if (data.error === 'slow_down') flow.pollInterval = Math.min(flow.pollInterval + 2000, 15000);
@@ -216,18 +256,48 @@ interface SemanticSearchResponse {
216
256
  const pool = new Map<string, Redis>();
217
257
 
218
258
  async function getConnection(instance_id: string): Promise<Redis> {
219
- if (pool.has(instance_id)) return pool.get(instance_id)!;
220
-
221
- const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
222
- if (inst.status !== 'running') {
259
+ if (!instance_id) {
223
260
  throw new McpError(
224
261
  ErrorCode.InvalidRequest,
225
- `Instance "${inst.name}" is not running (status: ${inst.status}). ` +
226
- `It may still be provisioning or awaiting payment.`
262
+ 'No instance_id provided and no running instance could be resolved automatically.\n\n' +
263
+ '• Set CACHLY_BRAIN_INSTANCE_ID in your MCP config, or\n' +
264
+ '• Pass instance_id explicitly to the tool, or\n' +
265
+ '• Run `list_instances` to see your available instances.'
227
266
  );
228
267
  }
268
+
269
+ if (pool.has(instance_id)) return pool.get(instance_id)!;
270
+
271
+ // Fetch instance, waiting up to 25 s if it is still provisioning.
272
+ // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
273
+ // all happen in quick succession and the instance isn't running yet.
274
+ let inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
275
+ if (inst.status === 'provisioning') {
276
+ const deadline = Date.now() + 25_000;
277
+ while (inst.status === 'provisioning' && Date.now() < deadline) {
278
+ await new Promise(r => setTimeout(r, 3000));
279
+ inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
280
+ }
281
+ }
282
+
283
+ if (inst.status !== 'running') {
284
+ // Telemetry: unreachable instance so the team can proactively investigate
285
+ void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
288
+ body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
289
+ signal: AbortSignal.timeout(3000),
290
+ }).catch(() => {});
291
+ const hint = inst.status === 'provisioning'
292
+ ? `⏳ Brain instance "${inst.name}" is still starting up.\n\nThis usually finishes within 30 seconds. Please try again in a moment.`
293
+ : `Brain instance "${inst.name}" is not reachable (status: ${inst.status}).\n\n` +
294
+ `• View your instance at: https://cachly.dev/instances\n` +
295
+ `• Run \`get_api_status\` for a full diagnostic.`;
296
+ throw new McpError(ErrorCode.InvalidRequest, hint);
297
+ }
229
298
  if (!inst.host || !inst.port) {
230
- throw new McpError(ErrorCode.InternalError, `Instance "${inst.name}" has no host/port yet.`);
299
+ throw new McpError(ErrorCode.InternalError,
300
+ `Instance "${inst.name}" is running but has no host/port — contact support@cachly.dev.`);
231
301
  }
232
302
 
233
303
  // Fetch connection details (includes password) from the dedicated endpoint.
@@ -240,7 +310,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
240
310
  password = conn.password ?? undefined;
241
311
  tlsEnabled = conn.tls_enabled !== false;
242
312
  } catch {
243
- // Fallback: no password, use TLS default from instance
313
+ // Fallback: use TLS flag from instance metadata
244
314
  }
245
315
 
246
316
  const client = new Redis({
@@ -248,26 +318,33 @@ async function getConnection(instance_id: string): Promise<Redis> {
248
318
  port: inst.port,
249
319
  password: password || undefined,
250
320
  ...(tlsEnabled ? { tls: {} } : {}),
251
- lazyConnect: true, // don't auto-connect — we connect explicitly below
321
+ lazyConnect: true,
252
322
  enableReadyCheck: true,
253
- connectTimeout: 5000,
323
+ connectTimeout: 8000,
254
324
  retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
255
325
  });
256
326
 
257
- client.on('error', () => {
258
- pool.delete(instance_id); // remove stale connection on error
259
- });
327
+ const evict = () => pool.delete(instance_id);
328
+ client.on('error', evict);
329
+ client.on('end', evict);
260
330
 
261
- // Connect explicitly so we can catch connection errors as a proper rejected
262
- // Promise instead of an unhandled 'error' event that would kill the process.
263
331
  try {
264
332
  await client.connect();
265
333
  } catch (err: unknown) {
266
334
  client.disconnect();
267
335
  const msg = err instanceof Error ? err.message : String(err);
336
+ void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
337
+ method: 'POST',
338
+ headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
339
+ body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
340
+ signal: AbortSignal.timeout(3000),
341
+ }).catch(() => {});
268
342
  throw new McpError(
269
343
  ErrorCode.InternalError,
270
- `Could not connect to instance "${inst.name}" (${inst.host}:${inst.port}): ${msg}`
344
+ `Could not reach Brain instance "${inst.name}" (${inst.host}:${inst.port}).\n\n` +
345
+ `• Check your network connection\n` +
346
+ `• Run \`get_api_status\` to verify instance status\n` +
347
+ `• Technical detail: ${msg}`
271
348
  );
272
349
  }
273
350
 
@@ -537,6 +614,44 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
537
614
  authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
538
615
  }
539
616
 
617
+ // List instances + connectivity
618
+ let instanceInfo = '';
619
+ try {
620
+ const listRes = await apiFetch<{ data: Instance[] }>('/api/v1/instances');
621
+ const instances = listRes.data ?? [];
622
+ if (instances.length === 0) {
623
+ instanceInfo = '\n\n🧠 **Brain Instances:** none — create one at https://cachly.dev/instances';
624
+ } else {
625
+ const lines: string[] = ['\n\n🧠 **Brain Instances:**'];
626
+ for (const inst of instances) {
627
+ const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
628
+ const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
629
+ lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
630
+ if (inst.status === 'running' && inst.host) {
631
+ // Try a quick ping on the cached connection
632
+ const pooled = pool.get(inst.id);
633
+ if (pooled) {
634
+ try {
635
+ await pooled.ping();
636
+ lines.push(` 💓 Redis: connected`);
637
+ } catch {
638
+ lines.push(` ⚠️ Redis: pooled but ping failed — will reconnect on next use`);
639
+ }
640
+ } else {
641
+ lines.push(` 💤 Redis: not connected yet (connects on first tool use)`);
642
+ }
643
+ } else if (inst.status === 'provisioning') {
644
+ lines.push(` ⏳ Still provisioning — check back in ~30 s`);
645
+ } else if (inst.status !== 'running') {
646
+ lines.push(` ❌ Not reachable (status: ${inst.status})`);
647
+ }
648
+ }
649
+ instanceInfo = lines.join('\n');
650
+ }
651
+ } catch (e) {
652
+ instanceInfo = `\n\n🧠 **Brain Instances:** could not fetch — ${(e as Error).message}`;
653
+ }
654
+
540
655
  return [
541
656
  `📡 **cachly API Status**`,
542
657
  ``,
@@ -545,6 +660,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
545
660
  ``,
546
661
  `🔑 **Auth:**`,
547
662
  authInfo,
663
+ instanceInfo,
548
664
  ].join('\n');
549
665
  }
550
666
 
@@ -582,8 +698,9 @@ async function autoStartSession(instanceId: string): Promise<void> {
582
698
  _autoSessionInstanceId = instanceId;
583
699
  try {
584
700
  await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
585
- } catch { /* non-fatal — session tracking is a best-effort feature */ }
586
- _autoSessionStarting = null;
701
+ } catch { /* non-fatal — session tracking is a best-effort feature */ } finally {
702
+ _autoSessionStarting = null;
703
+ }
587
704
  })();
588
705
  await _autoSessionStarting;
589
706
 
@@ -632,7 +749,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
632
749
  // Skip session management tools to avoid recursion.
633
750
  const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
634
751
  if (!sessionTools.has(name) && !_autoSessionStarted && !_autoSessionStarting) {
635
- const instanceId = (args as Record<string, unknown>)?.instance_id as string | undefined;
752
+ const instanceId = ((args as Record<string, unknown>)?.instance_id as string | undefined)
753
+ || _defaultInstanceId
754
+ || process.env.CACHLY_BRAIN_INSTANCE_ID;
636
755
  if (instanceId) {
637
756
  await autoStartSession(instanceId).catch(() => undefined);
638
757
  }
package/src/tools.ts CHANGED
@@ -437,10 +437,11 @@ const TOOLS = [
437
437
  {
438
438
  name: 'get_api_status',
439
439
  description:
440
- 'Check the cachly API health and your authentication status. ' +
441
- 'Returns whether the JWT is valid, your user ID (sub claim), ' +
442
- 'token expiry, and the auth provider (keycloak). ' +
443
- 'Use this to debug connection issues or verify your CACHLY_JWT is correct.',
440
+ 'Full diagnostic for your cachly Brain — call this FIRST whenever anything is not working. ' +
441
+ 'Returns: API reachability, JWT validity + expiry, your user ID, ' +
442
+ 'all Brain instances with live status (🟢 running / 🟡 provisioning / 🔴 stopped), ' +
443
+ 'Redis ping on the active connection, and actionable fix steps for every issue found. ' +
444
+ 'Workflow: run get_api_status → read the issue it flags → fix it → retry your tool.',
444
445
  inputSchema: { type: 'object', properties: {}, required: [] },
445
446
  },
446
447
  // ── Thinking/Context Cache (for AI assistants) ────────────────────────────
package/src/utils.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Safely parses a JSON string. Returns `fallback` on null, empty, or malformed
2
+ // input instead of throwing a SyntaxError that would fail the entire tool call.
3
+ export function safeJsonParse<T>(raw: string | null | undefined, fallback: T): T {
4
+ if (!raw) return fallback;
5
+ try {
6
+ return JSON.parse(raw) as T;
7
+ } catch {
8
+ return fallback;
9
+ }
10
+ }