@cachly-dev/mcp-server 0.10.4 → 0.10.6
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 +45 -0
- package/LAUNCH_TEXTS.md +182 -0
- package/LICENSE +202 -0
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +107 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/auth.d.ts +3 -0
- package/dist/auth.js +32 -0
- package/dist/auth.js.map +1 -0
- package/dist/handlers/advanced.js +38 -31
- package/dist/handlers/advanced.js.map +1 -1
- package/dist/handlers/brain.js +144 -50
- 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/roadmap.js +8 -2
- package/dist/handlers/roadmap.js.map +1 -1
- package/dist/handlers/syndicate.js +15 -7
- package/dist/handlers/syndicate.js.map +1 -1
- package/dist/handlers/team.js +62 -50
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +222 -217
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +0 -8
- package/dist/tools.js +5 -15
- 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 +29 -0
- package/llms.txt +155 -0
- package/package.json +29 -13
- package/scripts/postinstall.js +12 -19
- package/server.json +3 -9
- package/smithery.yaml +6 -8
- package/src/__tests__/auth.test.ts +147 -0
- package/src/auth.ts +46 -0
- package/src/handlers/advanced.ts +37 -23
- package/src/handlers/brain.ts +141 -47
- package/src/handlers/fedbrain.ts +43 -31
- package/src/handlers/roadmap.ts +7 -2
- package/src/handlers/syndicate.ts +13 -7
- package/src/handlers/team.ts +57 -33
- package/src/index.ts +224 -234
- package/src/tools.ts +5 -16
- package/src/utils.ts +10 -0
- package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
- package/dist/__tests__/brain-predict-failures.test.js +0 -339
- package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
- package/src/__tests__/brain-predict-failures.test.ts +0 -430
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
|
|
2
3
|
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import { execSync } from 'node:child_process';
|
|
4
5
|
import { join } from 'node:path';
|
|
@@ -52,19 +53,22 @@ import { Redis } from 'ioredis';
|
|
|
52
53
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
53
54
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
54
55
|
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
55
|
-
const CURRENT_VERSION = '0.
|
|
56
|
+
const CURRENT_VERSION = '0.10.0';
|
|
56
57
|
|
|
57
58
|
// ── Default Instance Resolution (for Smithery & single-credential setups) ────
|
|
58
59
|
// When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
|
|
59
60
|
// When neither is set, we auto-fetch the first running instance once per process.
|
|
60
61
|
let _defaultInstanceId: string = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
|
|
61
|
-
|
|
62
|
+
// Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
|
|
63
|
+
let _defaultInstanceLastAttempt = 0;
|
|
62
64
|
|
|
63
65
|
async function resolveDefaultInstanceId(): Promise<string> {
|
|
64
66
|
if (_defaultInstanceId) return _defaultInstanceId;
|
|
65
|
-
if (_defaultInstanceFetched) return '';
|
|
66
|
-
_defaultInstanceFetched = true;
|
|
67
67
|
if (!JWT) return '';
|
|
68
|
+
// Cooldown: don't hammer the API on every tool call after a transient failure.
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000) return '';
|
|
71
|
+
_defaultInstanceLastAttempt = now;
|
|
68
72
|
try {
|
|
69
73
|
const res = await fetch(`${API_URL}/api/v1/instances`, {
|
|
70
74
|
headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
|
|
@@ -72,9 +76,15 @@ async function resolveDefaultInstanceId(): Promise<string> {
|
|
|
72
76
|
});
|
|
73
77
|
if (!res.ok) return '';
|
|
74
78
|
const data = await res.json() as { data?: Array<{ id: string; status: string }> };
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
const instances = (data?.data ?? []) as Array<{ id: string; status: string }>;
|
|
80
|
+
// Prefer running, fall back to provisioning so the ID is resolved even during startup.
|
|
81
|
+
const best = instances.find(i => i.status === 'running') ?? instances.find(i => i.status === 'provisioning');
|
|
82
|
+
if (best) {
|
|
83
|
+
_defaultInstanceId = best.id;
|
|
84
|
+
_defaultInstanceLastAttempt = 0;
|
|
85
|
+
return _defaultInstanceId;
|
|
86
|
+
}
|
|
87
|
+
} catch { /* transient error — will retry after cooldown */ }
|
|
78
88
|
return '';
|
|
79
89
|
}
|
|
80
90
|
|
|
@@ -149,24 +159,55 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
149
159
|
JWT = apiKey;
|
|
150
160
|
setEmbedJwt(apiKey); // keep embeddings.ts in sync
|
|
151
161
|
_deviceFlow = null;
|
|
152
|
-
// Auto-provision instance
|
|
153
|
-
|
|
162
|
+
// Auto-provision: find or create the user's brain instance.
|
|
163
|
+
_defaultInstanceLastAttempt = 0;
|
|
154
164
|
await resolveDefaultInstanceId();
|
|
155
165
|
if (!_defaultInstanceId) {
|
|
156
|
-
// Try auto-provision
|
|
157
166
|
try {
|
|
158
167
|
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
159
168
|
method: 'POST',
|
|
160
169
|
headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
|
|
161
|
-
signal: AbortSignal.timeout(
|
|
170
|
+
signal: AbortSignal.timeout(12000),
|
|
162
171
|
});
|
|
163
172
|
if (autoRes.ok) {
|
|
164
|
-
const body = await autoRes.json() as {
|
|
173
|
+
const body = await autoRes.json() as {
|
|
174
|
+
instance?: { id: string; status?: string };
|
|
175
|
+
instance_id?: string;
|
|
176
|
+
status?: string;
|
|
177
|
+
};
|
|
165
178
|
const id = body.instance?.id ?? body.instance_id;
|
|
166
179
|
if (id) _defaultInstanceId = id;
|
|
167
180
|
}
|
|
168
181
|
} catch { /* non-fatal */ }
|
|
169
182
|
}
|
|
183
|
+
// Give a provisioning instance a head-start so getConnection's wait is shorter.
|
|
184
|
+
// The instance is almost always ready by the time the user's tool re-enters.
|
|
185
|
+
if (_defaultInstanceId) {
|
|
186
|
+
try {
|
|
187
|
+
const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
188
|
+
headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
|
|
189
|
+
signal: AbortSignal.timeout(4000),
|
|
190
|
+
});
|
|
191
|
+
if (checkRes.ok) {
|
|
192
|
+
const checkInst = await checkRes.json() as { status?: string };
|
|
193
|
+
if (checkInst.status === 'provisioning') {
|
|
194
|
+
// Wait up to 20s here so the re-entered tool call lands on a running instance.
|
|
195
|
+
const pDeadline = Date.now() + 20_000;
|
|
196
|
+
while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
|
|
197
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
198
|
+
const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
199
|
+
headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
|
|
200
|
+
signal: AbortSignal.timeout(4000),
|
|
201
|
+
}).catch(() => null);
|
|
202
|
+
if (rr?.ok) {
|
|
203
|
+
const ri = await rr.json() as { status?: string };
|
|
204
|
+
(checkInst as { status?: string }).status = ri.status;
|
|
205
|
+
} else { break; }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch { /* non-fatal — getConnection will handle the wait if still provisioning */ }
|
|
210
|
+
}
|
|
170
211
|
return 'done';
|
|
171
212
|
}
|
|
172
213
|
if (data.error === 'slow_down') flow.pollInterval = Math.min(flow.pollInterval + 2000, 15000);
|
|
@@ -216,18 +257,48 @@ interface SemanticSearchResponse {
|
|
|
216
257
|
const pool = new Map<string, Redis>();
|
|
217
258
|
|
|
218
259
|
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') {
|
|
260
|
+
if (!instance_id) {
|
|
223
261
|
throw new McpError(
|
|
224
262
|
ErrorCode.InvalidRequest,
|
|
225
|
-
|
|
226
|
-
|
|
263
|
+
'No instance_id provided and no running instance could be resolved automatically.\n\n' +
|
|
264
|
+
'• Set CACHLY_BRAIN_INSTANCE_ID in your MCP config, or\n' +
|
|
265
|
+
'• Pass instance_id explicitly to the tool, or\n' +
|
|
266
|
+
'• Run `list_instances` to see your available instances.'
|
|
227
267
|
);
|
|
228
268
|
}
|
|
269
|
+
|
|
270
|
+
if (pool.has(instance_id)) return pool.get(instance_id)!;
|
|
271
|
+
|
|
272
|
+
// Fetch instance, waiting up to 25 s if it is still provisioning.
|
|
273
|
+
// This covers the zero-friction path: device-flow auth → auto-provision → first tool call
|
|
274
|
+
// all happen in quick succession and the instance isn't running yet.
|
|
275
|
+
let inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
276
|
+
if (inst.status === 'provisioning') {
|
|
277
|
+
const deadline = Date.now() + 25_000;
|
|
278
|
+
while (inst.status === 'provisioning' && Date.now() < deadline) {
|
|
279
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
280
|
+
inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (inst.status !== 'running') {
|
|
285
|
+
// Telemetry: unreachable instance so the team can proactively investigate
|
|
286
|
+
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
|
|
289
|
+
body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
|
|
290
|
+
signal: AbortSignal.timeout(3000),
|
|
291
|
+
}).catch(() => {});
|
|
292
|
+
const hint = inst.status === 'provisioning'
|
|
293
|
+
? `⏳ Brain instance "${inst.name}" is still starting up.\n\nThis usually finishes within 30 seconds. Please try again in a moment.`
|
|
294
|
+
: `Brain instance "${inst.name}" is not reachable (status: ${inst.status}).\n\n` +
|
|
295
|
+
`• View your instance at: https://cachly.dev/instances\n` +
|
|
296
|
+
`• Run \`get_api_status\` for a full diagnostic.`;
|
|
297
|
+
throw new McpError(ErrorCode.InvalidRequest, hint);
|
|
298
|
+
}
|
|
229
299
|
if (!inst.host || !inst.port) {
|
|
230
|
-
throw new McpError(ErrorCode.InternalError,
|
|
300
|
+
throw new McpError(ErrorCode.InternalError,
|
|
301
|
+
`Instance "${inst.name}" is running but has no host/port — contact support@cachly.dev.`);
|
|
231
302
|
}
|
|
232
303
|
|
|
233
304
|
// Fetch connection details (includes password) from the dedicated endpoint.
|
|
@@ -240,7 +311,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
240
311
|
password = conn.password ?? undefined;
|
|
241
312
|
tlsEnabled = conn.tls_enabled !== false;
|
|
242
313
|
} catch {
|
|
243
|
-
// Fallback:
|
|
314
|
+
// Fallback: use TLS flag from instance metadata
|
|
244
315
|
}
|
|
245
316
|
|
|
246
317
|
const client = new Redis({
|
|
@@ -248,26 +319,33 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
248
319
|
port: inst.port,
|
|
249
320
|
password: password || undefined,
|
|
250
321
|
...(tlsEnabled ? { tls: {} } : {}),
|
|
251
|
-
lazyConnect: true,
|
|
322
|
+
lazyConnect: true,
|
|
252
323
|
enableReadyCheck: true,
|
|
253
|
-
connectTimeout:
|
|
324
|
+
connectTimeout: 8000,
|
|
254
325
|
retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
|
|
255
326
|
});
|
|
256
327
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
328
|
+
const evict = () => pool.delete(instance_id);
|
|
329
|
+
client.on('error', evict);
|
|
330
|
+
client.on('end', evict);
|
|
260
331
|
|
|
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
332
|
try {
|
|
264
333
|
await client.connect();
|
|
265
334
|
} catch (err: unknown) {
|
|
266
335
|
client.disconnect();
|
|
267
336
|
const msg = err instanceof Error ? err.message : String(err);
|
|
337
|
+
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
|
|
340
|
+
body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
|
|
341
|
+
signal: AbortSignal.timeout(3000),
|
|
342
|
+
}).catch(() => {});
|
|
268
343
|
throw new McpError(
|
|
269
344
|
ErrorCode.InternalError,
|
|
270
|
-
`Could not
|
|
345
|
+
`Could not reach Brain instance "${inst.name}" (${inst.host}:${inst.port}).\n\n` +
|
|
346
|
+
`• Check your network connection\n` +
|
|
347
|
+
`• Run \`get_api_status\` to verify instance status\n` +
|
|
348
|
+
`• Technical detail: ${msg}`
|
|
271
349
|
);
|
|
272
350
|
}
|
|
273
351
|
|
|
@@ -277,33 +355,8 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
277
355
|
|
|
278
356
|
// ── API helper ────────────────────────────────────────────────────────────────
|
|
279
357
|
|
|
280
|
-
function jwtExpiryMs(token: string): number | null {
|
|
281
|
-
try {
|
|
282
|
-
const parts = token.split('.');
|
|
283
|
-
if (parts.length !== 3) return null;
|
|
284
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as { exp?: number };
|
|
285
|
-
return payload.exp ? payload.exp * 1000 : null;
|
|
286
|
-
} catch { return null; }
|
|
287
|
-
}
|
|
288
|
-
|
|
289
358
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
290
|
-
|
|
291
|
-
throw new McpError(
|
|
292
|
-
ErrorCode.InvalidRequest,
|
|
293
|
-
'CACHLY_JWT env var not set.\n\nGet your token at https://cachly.dev/setup-ai and add it to your MCP config:\n CACHLY_JWT=<your-token>'
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Detect token expiry before making the network call — gives a clearer error
|
|
298
|
-
const expMs = jwtExpiryMs(JWT);
|
|
299
|
-
if (expMs !== null && expMs < Date.now()) {
|
|
300
|
-
const expiredAt = new Date(expMs).toISOString();
|
|
301
|
-
throw new McpError(
|
|
302
|
-
ErrorCode.InvalidRequest,
|
|
303
|
-
`CACHLY_JWT expired at ${expiredAt}.\n\nGet a fresh token at https://cachly.dev/setup-ai and update CACHLY_JWT in your MCP config.`
|
|
304
|
-
);
|
|
305
|
-
}
|
|
306
|
-
|
|
359
|
+
checkJwt(JWT);
|
|
307
360
|
const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
|
|
308
361
|
const res = await fetch(`${API_URL}${path}`, {
|
|
309
362
|
...options,
|
|
@@ -317,22 +370,7 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|
|
317
370
|
if (!res.ok) {
|
|
318
371
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
319
372
|
const detail = (body as { error?: string }).error ?? res.statusText;
|
|
320
|
-
|
|
321
|
-
throw new McpError(
|
|
322
|
-
ErrorCode.InvalidRequest,
|
|
323
|
-
`Authentication failed (401): ${detail}\n\nYour CACHLY_JWT may be expired or invalid. Get a fresh token at https://cachly.dev/setup-ai`
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
if (res.status === 403) {
|
|
327
|
-
throw new McpError(
|
|
328
|
-
ErrorCode.InvalidRequest,
|
|
329
|
-
`Access denied (403): ${detail}\n\nCheck that your CACHLY_JWT belongs to an account with access to this resource.`
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
throw new McpError(
|
|
333
|
-
ErrorCode.InternalError,
|
|
334
|
-
`cachly API error ${res.status}: ${detail}`
|
|
335
|
-
);
|
|
373
|
+
handleApiError(res.status, detail);
|
|
336
374
|
}
|
|
337
375
|
if (res.status === 204) return undefined as T;
|
|
338
376
|
return res.json() as Promise<T>;
|
|
@@ -360,47 +398,6 @@ import { TOOLS } from './tools.js';
|
|
|
360
398
|
|
|
361
399
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
|
362
400
|
|
|
363
|
-
// ── Tool-call analytics (opt-out: CACHLY_NO_TELEMETRY=1) ────────────────────
|
|
364
|
-
// In-memory counters — exposed via /stats on HTTP mode
|
|
365
|
-
const _toolCallCounts: Record<string, number> = {};
|
|
366
|
-
let _toolCallTotal = 0;
|
|
367
|
-
let _toolCallAuthedTotal = 0;
|
|
368
|
-
|
|
369
|
-
async function trackToolCall(toolName: string, authed: boolean, durationMs: number): Promise<void> {
|
|
370
|
-
if (process.env.CACHLY_NO_TELEMETRY === '1') return;
|
|
371
|
-
// Increment in-memory counters
|
|
372
|
-
_toolCallTotal++;
|
|
373
|
-
if (authed) _toolCallAuthedTotal++;
|
|
374
|
-
_toolCallCounts[toolName] = (_toolCallCounts[toolName] ?? 0) + 1;
|
|
375
|
-
// Structured JSON to stdout — searchable via: docker logs cachly-mcp-http-1 | jq
|
|
376
|
-
process.stdout.write(JSON.stringify({
|
|
377
|
-
ts: new Date().toISOString(),
|
|
378
|
-
event: 'tool_call',
|
|
379
|
-
tool: toolName,
|
|
380
|
-
authed,
|
|
381
|
-
ms: durationMs,
|
|
382
|
-
v: CURRENT_VERSION,
|
|
383
|
-
}) + '\n');
|
|
384
|
-
// Plausible custom event (fire-and-forget, never blocks user)
|
|
385
|
-
try {
|
|
386
|
-
await fetch('https://analytics.cachly.dev/api/event', {
|
|
387
|
-
method: 'POST',
|
|
388
|
-
headers: {
|
|
389
|
-
'Content-Type': 'application/json',
|
|
390
|
-
'User-Agent': `cachly-mcp/${CURRENT_VERSION}`,
|
|
391
|
-
'X-Forwarded-For': '127.0.0.1',
|
|
392
|
-
},
|
|
393
|
-
body: JSON.stringify({
|
|
394
|
-
domain: 'mcp.cachly.dev',
|
|
395
|
-
name: 'tool_call',
|
|
396
|
-
url: `https://mcp.cachly.dev/mcp/${toolName}`,
|
|
397
|
-
props: { tool: toolName, authed: String(authed) },
|
|
398
|
-
}),
|
|
399
|
-
signal: AbortSignal.timeout(3000),
|
|
400
|
-
});
|
|
401
|
-
} catch { /* fire-and-forget */ }
|
|
402
|
-
}
|
|
403
|
-
|
|
404
401
|
// Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
|
|
405
402
|
let _telemetryPingSent = false;
|
|
406
403
|
async function sendAnonymousTelemetry(toolName: string): Promise<void> {
|
|
@@ -424,61 +421,6 @@ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
|
|
|
424
421
|
}
|
|
425
422
|
|
|
426
423
|
async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
427
|
-
// ── sign_in: explicit device-flow trigger — works even without JWT ────────
|
|
428
|
-
if (name === 'sign_in') {
|
|
429
|
-
if (JWT) {
|
|
430
|
-
return '✅ **Already signed in.** Your cachly AI Brain is ready — all 63 tools available.';
|
|
431
|
-
}
|
|
432
|
-
// Poll if flow already in progress
|
|
433
|
-
if (_deviceFlow) {
|
|
434
|
-
const result = await pollDeviceFlow(_deviceFlow);
|
|
435
|
-
if (result === 'done') {
|
|
436
|
-
return [
|
|
437
|
-
'✅ **Sign-in complete!** Your cachly AI Brain is ready.',
|
|
438
|
-
'',
|
|
439
|
-
'All 63 brain tools are now available. Start with `session_start` to load context from your last session.',
|
|
440
|
-
].join('\n');
|
|
441
|
-
}
|
|
442
|
-
if (result === 'expired') {
|
|
443
|
-
_deviceFlow = null;
|
|
444
|
-
// fall through to start a new flow
|
|
445
|
-
} else {
|
|
446
|
-
return [
|
|
447
|
-
'⏳ **Waiting for you to approve in the browser...**',
|
|
448
|
-
'',
|
|
449
|
-
`Sign in at: **${_deviceFlow.verifyUrl}**`,
|
|
450
|
-
`Code: **${_deviceFlow.userCode}**`,
|
|
451
|
-
'',
|
|
452
|
-
'After approving, call `sign_in` again — it will complete automatically.',
|
|
453
|
-
].join('\n');
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
const flow = await startDeviceFlow();
|
|
457
|
-
if (flow) {
|
|
458
|
-
_deviceFlow = flow;
|
|
459
|
-
return [
|
|
460
|
-
'🧠 **cachly AI Brain — One-click sign in**',
|
|
461
|
-
'',
|
|
462
|
-
'1. Open this URL (it may open automatically):',
|
|
463
|
-
` **${flow.verifyUrl}**`,
|
|
464
|
-
'',
|
|
465
|
-
`2. If prompted for a code, enter: **${flow.userCode}**`,
|
|
466
|
-
'',
|
|
467
|
-
'3. After approving in your browser, call `sign_in` again — it will complete automatically.',
|
|
468
|
-
'',
|
|
469
|
-
'✨ Free tier: 1 Brain instance · persistent memory · 63 MCP tools · no credit card required.',
|
|
470
|
-
].join('\n');
|
|
471
|
-
}
|
|
472
|
-
return [
|
|
473
|
-
'🧠 **cachly AI Brain — Setup required**',
|
|
474
|
-
'',
|
|
475
|
-
'Run once in your terminal:',
|
|
476
|
-
' `npx @cachly-dev/mcp-server@latest setup`',
|
|
477
|
-
'',
|
|
478
|
-
'Or sign up at: https://cachly.dev/setup-ai',
|
|
479
|
-
].join('\n');
|
|
480
|
-
}
|
|
481
|
-
|
|
482
424
|
// Guard: if no JWT, return actionable onboarding message instead of HTTP 401
|
|
483
425
|
if (!JWT) {
|
|
484
426
|
void sendAnonymousTelemetry(name);
|
|
@@ -541,10 +483,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
541
483
|
].join('\n');
|
|
542
484
|
}
|
|
543
485
|
|
|
544
|
-
// Track authenticated tool call (fire-and-forget)
|
|
545
|
-
const _trackStart = Date.now();
|
|
546
|
-
void (async () => { await trackToolCall(name, true, Date.now() - _trackStart); })();
|
|
547
|
-
|
|
548
486
|
// Auto-resolve instance_id from env / API when not provided in args
|
|
549
487
|
if (!args.instance_id) {
|
|
550
488
|
const defaultId = await resolveDefaultInstanceId();
|
|
@@ -637,6 +575,44 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
637
575
|
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
638
576
|
}
|
|
639
577
|
|
|
578
|
+
// List instances + connectivity
|
|
579
|
+
let instanceInfo = '';
|
|
580
|
+
try {
|
|
581
|
+
const listRes = await apiFetch<{ data: Instance[] }>('/api/v1/instances');
|
|
582
|
+
const instances = listRes.data ?? [];
|
|
583
|
+
if (instances.length === 0) {
|
|
584
|
+
instanceInfo = '\n\n🧠 **Brain Instances:** none — create one at https://cachly.dev/instances';
|
|
585
|
+
} else {
|
|
586
|
+
const lines: string[] = ['\n\n🧠 **Brain Instances:**'];
|
|
587
|
+
for (const inst of instances) {
|
|
588
|
+
const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
|
|
589
|
+
const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
|
|
590
|
+
lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
|
|
591
|
+
if (inst.status === 'running' && inst.host) {
|
|
592
|
+
// Try a quick ping on the cached connection
|
|
593
|
+
const pooled = pool.get(inst.id);
|
|
594
|
+
if (pooled) {
|
|
595
|
+
try {
|
|
596
|
+
await pooled.ping();
|
|
597
|
+
lines.push(` 💓 Redis: connected`);
|
|
598
|
+
} catch {
|
|
599
|
+
lines.push(` ⚠️ Redis: pooled but ping failed — will reconnect on next use`);
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
lines.push(` 💤 Redis: not connected yet (connects on first tool use)`);
|
|
603
|
+
}
|
|
604
|
+
} else if (inst.status === 'provisioning') {
|
|
605
|
+
lines.push(` ⏳ Still provisioning — check back in ~30 s`);
|
|
606
|
+
} else if (inst.status !== 'running') {
|
|
607
|
+
lines.push(` ❌ Not reachable (status: ${inst.status})`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
instanceInfo = lines.join('\n');
|
|
611
|
+
}
|
|
612
|
+
} catch (e) {
|
|
613
|
+
instanceInfo = `\n\n🧠 **Brain Instances:** could not fetch — ${(e as Error).message}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
640
616
|
return [
|
|
641
617
|
`📡 **cachly API Status**`,
|
|
642
618
|
``,
|
|
@@ -645,6 +621,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
645
621
|
``,
|
|
646
622
|
`🔑 **Auth:**`,
|
|
647
623
|
authInfo,
|
|
624
|
+
instanceInfo,
|
|
648
625
|
].join('\n');
|
|
649
626
|
}
|
|
650
627
|
|
|
@@ -658,15 +635,7 @@ const server = new Server(
|
|
|
658
635
|
{ capabilities: { tools: {} } }
|
|
659
636
|
);
|
|
660
637
|
|
|
661
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
662
|
-
if (!JWT) {
|
|
663
|
-
// Surface sign_in as the only prominent tool when not authenticated,
|
|
664
|
-
// so AI assistants proactively call it before attempting any brain tool.
|
|
665
|
-
const signInTool = TOOLS.find(t => t.name === 'sign_in');
|
|
666
|
-
return { tools: signInTool ? [signInTool] : TOOLS };
|
|
667
|
-
}
|
|
668
|
-
return { tools: TOOLS };
|
|
669
|
-
});
|
|
638
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
670
639
|
|
|
671
640
|
// ── Auto-session management ───────────────────────────────────────────────────
|
|
672
641
|
// Transparently starts a Brain session on the first tool call of a process
|
|
@@ -690,8 +659,9 @@ async function autoStartSession(instanceId: string): Promise<void> {
|
|
|
690
659
|
_autoSessionInstanceId = instanceId;
|
|
691
660
|
try {
|
|
692
661
|
await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
|
|
693
|
-
} catch { /* non-fatal — session tracking is a best-effort feature */ }
|
|
694
|
-
|
|
662
|
+
} catch { /* non-fatal — session tracking is a best-effort feature */ } finally {
|
|
663
|
+
_autoSessionStarting = null;
|
|
664
|
+
}
|
|
695
665
|
})();
|
|
696
666
|
await _autoSessionStarting;
|
|
697
667
|
|
|
@@ -740,7 +710,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
740
710
|
// Skip session management tools to avoid recursion.
|
|
741
711
|
const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
|
|
742
712
|
if (!sessionTools.has(name) && !_autoSessionStarted && !_autoSessionStarting) {
|
|
743
|
-
const instanceId = (args as Record<string, unknown>)?.instance_id as string | undefined
|
|
713
|
+
const instanceId = ((args as Record<string, unknown>)?.instance_id as string | undefined)
|
|
714
|
+
|| _defaultInstanceId
|
|
715
|
+
|| process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
744
716
|
if (instanceId) {
|
|
745
717
|
await autoStartSession(instanceId).catch(() => undefined);
|
|
746
718
|
}
|
|
@@ -833,54 +805,47 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
|
|
|
833
805
|
|
|
834
806
|
function buildClaudeMdBlock(instanceId: string): string {
|
|
835
807
|
return `${CLAUDE_MD_MARKER_START}
|
|
836
|
-
## Cachly AI Brain
|
|
808
|
+
## Cachly AI Brain — Always Active
|
|
837
809
|
|
|
838
|
-
The
|
|
839
|
-
Your instance ID: \`${instanceId}\`
|
|
810
|
+
The Brain MCP server runs automatically. Instance: \`${instanceId}\`
|
|
840
811
|
|
|
841
|
-
|
|
842
|
-
> The Brain starts tracking when you call any tool for the first time and saves a summary when your editor closes. You never need to call \`session_start\` or \`session_end\` manually.
|
|
812
|
+
Sessions start and end automatically — never call \`session_start\` or \`session_end\`.
|
|
843
813
|
|
|
844
814
|
---
|
|
845
815
|
|
|
846
|
-
###
|
|
816
|
+
### BEFORE every task (mandatory — no exceptions):
|
|
817
|
+
\`\`\`
|
|
818
|
+
smart_recall(instance_id="${instanceId}", query="<what you are about to do>")
|
|
819
|
+
\`\`\`
|
|
820
|
+
Report any relevant lessons found to the user before proceeding.
|
|
821
|
+
|
|
822
|
+
### BEFORE editing any file (mandatory):
|
|
847
823
|
\`\`\`
|
|
848
|
-
remember_context(
|
|
849
|
-
instance_id = "${instanceId}",
|
|
850
|
-
key = "wip-registry",
|
|
851
|
-
content = "EDITING: <file:line> — goal: <what you are implementing>",
|
|
852
|
-
category = "bug",
|
|
853
|
-
ttl = 86400,
|
|
854
|
-
)
|
|
824
|
+
remember_context(instance_id="${instanceId}", key="wip:<filename>", content="EDITING: <file:line> — <goal>", ttl=86400)
|
|
855
825
|
\`\`\`
|
|
856
|
-
|
|
826
|
+
When done: update content to \`"DONE: <file> — <summary>"\`.
|
|
857
827
|
|
|
858
|
-
###
|
|
828
|
+
### AFTER every fix, deploy, or discovery (mandatory):
|
|
859
829
|
\`\`\`
|
|
860
|
-
learn_from_attempts(
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
outcome = "success",
|
|
864
|
-
what_worked = "...",
|
|
865
|
-
what_failed = "...",
|
|
866
|
-
severity = "critical" | "major" | "minor",
|
|
867
|
-
file_paths = ["path/to/file"],
|
|
868
|
-
commands = ["the command that worked"],
|
|
869
|
-
tags = ["tag1"],
|
|
870
|
-
)
|
|
830
|
+
learn_from_attempts(instance_id="${instanceId}", topic="<category:keyword>", outcome="success"|"failure",
|
|
831
|
+
what_worked="...", what_failed="...", severity="critical"|"major"|"minor",
|
|
832
|
+
file_paths=["..."], commands=["..."], tags=["..."])
|
|
871
833
|
\`\`\`
|
|
872
834
|
|
|
873
|
-
###
|
|
835
|
+
### WHEN debugging any error (mandatory before grepping/reading files):
|
|
874
836
|
\`\`\`
|
|
875
|
-
|
|
876
|
-
instance_id = "${instanceId}",
|
|
877
|
-
query = "<describe what you are about to do>",
|
|
878
|
-
)
|
|
837
|
+
causal_trace(instance_id="${instanceId}", problem="<exact error or symptom>")
|
|
879
838
|
\`\`\`
|
|
839
|
+
If a known fix exists in the Brain, use it directly. Skip research if Brain has the answer.
|
|
840
|
+
|
|
841
|
+
### BEFORE any risky operation (deploy, migration, dependency upgrade):
|
|
842
|
+
\`\`\`
|
|
843
|
+
brain_predict(instance_id="${instanceId}", context="<what you are about to do>")
|
|
844
|
+
\`\`\`
|
|
845
|
+
Report predicted failure risks to the user before proceeding.
|
|
880
846
|
|
|
881
847
|
### Half-finished code rule:
|
|
882
848
|
Never commit code that does not compile. Run \`tsc --noEmit\` / \`go build ./...\` before every commit.
|
|
883
|
-
If a session ends mid-task, save the WIP-registry entry so the next session picks up exactly where you left off.
|
|
884
849
|
${CLAUDE_MD_MARKER_END}`;
|
|
885
850
|
}
|
|
886
851
|
|
|
@@ -987,7 +952,7 @@ if (process.argv[2] === 'init') {
|
|
|
987
952
|
// Usage: npx @cachly-dev/mcp-server setup
|
|
988
953
|
|
|
989
954
|
if (process.argv[2] === 'setup') {
|
|
990
|
-
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
955
|
+
const { writeFile, mkdir, readFile } = await import('node:fs/promises');
|
|
991
956
|
const { existsSync } = await import('node:fs');
|
|
992
957
|
const { resolve, dirname } = await import('node:path');
|
|
993
958
|
const { createInterface } = await import('node:readline');
|
|
@@ -1216,7 +1181,7 @@ if (process.argv[2] === 'setup') {
|
|
|
1216
1181
|
const editorsToSetup = detected;
|
|
1217
1182
|
console.log(`Step 3: Configuring for: ${editorsToSetup.map(editorLabel).join(', ')}\n`);
|
|
1218
1183
|
|
|
1219
|
-
// ── Step 4: Write editor configs
|
|
1184
|
+
// ── Step 4: Write editor configs (project-level) ─────────────────────────
|
|
1220
1185
|
for (const editor of editorsToSetup) {
|
|
1221
1186
|
const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
|
|
1222
1187
|
const configPath = resolve(cwd, configFile);
|
|
@@ -1225,11 +1190,54 @@ if (process.argv[2] === 'setup') {
|
|
|
1225
1190
|
console.log(`✅ Written: ${configFile}`);
|
|
1226
1191
|
}
|
|
1227
1192
|
|
|
1193
|
+
// ── Step 4b: Write global Claude Code config (~/.claude/mcp.json) ─────────
|
|
1194
|
+
// Merges cachly into the existing global config so it works in every project,
|
|
1195
|
+
// without removing other MCP servers the user may have configured.
|
|
1196
|
+
const globalClaudePath = resolve(home, '.claude', 'mcp.json');
|
|
1197
|
+
try {
|
|
1198
|
+
await mkdir(dirname(globalClaudePath), { recursive: true });
|
|
1199
|
+
let globalConfig: { mcpServers?: Record<string, unknown> } = {};
|
|
1200
|
+
if (existsSync(globalClaudePath)) {
|
|
1201
|
+
try {
|
|
1202
|
+
globalConfig = JSON.parse(await readFile(globalClaudePath, 'utf-8')) as typeof globalConfig;
|
|
1203
|
+
} catch { /* corrupt — start fresh */ }
|
|
1204
|
+
}
|
|
1205
|
+
globalConfig.mcpServers ??= {};
|
|
1206
|
+
globalConfig.mcpServers['cachly'] = {
|
|
1207
|
+
command: 'npx',
|
|
1208
|
+
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
1209
|
+
env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: token, CACHLY_BRAIN_INSTANCE_ID: instance.id },
|
|
1210
|
+
};
|
|
1211
|
+
await writeFile(globalClaudePath, JSON.stringify(globalConfig, null, 2), 'utf-8');
|
|
1212
|
+
console.log(`✅ Written: ~/.claude/mcp.json (global — works in every project)`);
|
|
1213
|
+
} catch (e) {
|
|
1214
|
+
console.log(`⚠️ Could not write ~/.claude/mcp.json: ${(e as Error).message}`);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1228
1217
|
// ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
|
|
1229
1218
|
const mdResult = await writeClaudeMd(cwd, instance.id);
|
|
1230
1219
|
const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
|
|
1231
1220
|
console.log(`${mdLabel}: CLAUDE.md\n`);
|
|
1232
1221
|
|
|
1222
|
+
// ── Step 5b: Bootstrap Brain from git history ─────────────────────────────
|
|
1223
|
+
// Pre-populates the Brain with real lessons so the first session_start shows
|
|
1224
|
+
// actual project knowledge instead of an empty brain.
|
|
1225
|
+
if (existsSync(resolve(cwd, '.git'))) {
|
|
1226
|
+
process.stdout.write('⏳ Bootstrapping Brain from git history (~10s)...');
|
|
1227
|
+
try {
|
|
1228
|
+
JWT = token; // set global JWT so handleTool can authenticate
|
|
1229
|
+
const gitResult = await handleTool('brain_from_git', {
|
|
1230
|
+
instance_id: instance.id,
|
|
1231
|
+
dir: cwd,
|
|
1232
|
+
max_commits: 100,
|
|
1233
|
+
});
|
|
1234
|
+
const match = gitResult.match(/(\d+) lesson/);
|
|
1235
|
+
console.log(` ✓ ${match ? match[1] + ' lessons' : 'done'} extracted from git history`);
|
|
1236
|
+
} catch (e) {
|
|
1237
|
+
console.log(` (skipped: ${(e as Error).message.slice(0, 80)})`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1233
1241
|
// ── Step 6: Show Brain health (Aha moment) ────────────────────────────────
|
|
1234
1242
|
// Fetch brain health from the API to show the user what their agent will see.
|
|
1235
1243
|
process.stdout.write('\n⏳ Fetching your Brain health preview...');
|
|
@@ -1450,7 +1458,6 @@ if (httpPort) {
|
|
|
1450
1458
|
res.end(JSON.stringify({
|
|
1451
1459
|
status: 'ok',
|
|
1452
1460
|
version: CURRENT_VERSION,
|
|
1453
|
-
toolCalls: { total: _toolCallTotal, authed: _toolCallAuthedTotal },
|
|
1454
1461
|
zeroResults: {
|
|
1455
1462
|
total: zeroResultsTotal,
|
|
1456
1463
|
last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
|
|
@@ -1458,23 +1465,6 @@ if (httpPort) {
|
|
|
1458
1465
|
}));
|
|
1459
1466
|
return;
|
|
1460
1467
|
}
|
|
1461
|
-
if (url.pathname === '/stats') {
|
|
1462
|
-
// Top tools sorted by call count
|
|
1463
|
-
const top = Object.entries(_toolCallCounts)
|
|
1464
|
-
.sort(([, a], [, b]) => b - a)
|
|
1465
|
-
.slice(0, 20)
|
|
1466
|
-
.map(([tool, count]) => ({ tool, count }));
|
|
1467
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1468
|
-
res.end(JSON.stringify({
|
|
1469
|
-
version: CURRENT_VERSION,
|
|
1470
|
-
total: _toolCallTotal,
|
|
1471
|
-
authed: _toolCallAuthedTotal,
|
|
1472
|
-
unauthenticated: _toolCallTotal - _toolCallAuthedTotal,
|
|
1473
|
-
topTools: top,
|
|
1474
|
-
uptimeSec: Math.floor(process.uptime()),
|
|
1475
|
-
}));
|
|
1476
|
-
return;
|
|
1477
|
-
}
|
|
1478
1468
|
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
1479
1469
|
transport.handleRequest(req, res);
|
|
1480
1470
|
return;
|