@cachly-dev/mcp-server 0.9.25 → 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/.github/workflows/publish.yml +42 -0
- package/LICENSE +202 -0
- package/dist/handlers/brain.js +93 -33
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +55 -38
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/team.js +62 -50
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +147 -28
- package/dist/index.js.map +1 -1
- package/dist/tools.js +5 -4
- package/dist/tools.js.map +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +13 -0
- package/dist/utils.js.map +1 -0
- package/glama.json +6 -0
- package/package.json +1 -1
- package/src/handlers/brain.ts +89 -27
- package/src/handlers/fedbrain.ts +43 -31
- package/src/handlers/team.ts +57 -33
- package/src/index.ts +150 -31
- package/src/tools.ts +5 -4
- package/src/utils.ts +10 -0
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.
|
|
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
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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(
|
|
169
|
+
signal: AbortSignal.timeout(12000),
|
|
162
170
|
});
|
|
163
171
|
if (autoRes.ok) {
|
|
164
|
-
const body = await autoRes.json() as {
|
|
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 (
|
|
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
|
-
|
|
226
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
321
|
+
lazyConnect: true,
|
|
252
322
|
enableReadyCheck: true,
|
|
253
|
-
connectTimeout:
|
|
323
|
+
connectTimeout: 8000,
|
|
254
324
|
retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
|
|
255
325
|
});
|
|
256
326
|
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
'
|
|
441
|
-
'Returns
|
|
442
|
-
'
|
|
443
|
-
'
|
|
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
|
+
}
|