@cachly-dev/mcp-server 0.10.5 → 0.10.7
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/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/index.js +252 -217
- package/dist/index.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/package.json +1 -1
- package/src/auth.ts +46 -0
- package/src/handlers/advanced.ts +37 -23
- package/src/handlers/brain.ts +141 -47
- package/src/index.ts +251 -234
- package/src/utils.ts +10 -0
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
|
|
2
3
|
/**
|
|
3
4
|
* cachly MCP Server v0.4.0
|
|
4
5
|
*
|
|
@@ -41,20 +42,23 @@ import { Redis } from 'ioredis';
|
|
|
41
42
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
42
43
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
43
44
|
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
44
|
-
const CURRENT_VERSION = '0.10.
|
|
45
|
+
const CURRENT_VERSION = '0.10.0';
|
|
45
46
|
// ── Default Instance Resolution (for Smithery & single-credential setups) ────
|
|
46
47
|
// When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
|
|
47
48
|
// When neither is set, we auto-fetch the first running instance once per process.
|
|
48
49
|
let _defaultInstanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
|
|
49
|
-
|
|
50
|
+
// Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
|
|
51
|
+
let _defaultInstanceLastAttempt = 0;
|
|
50
52
|
async function resolveDefaultInstanceId() {
|
|
51
53
|
if (_defaultInstanceId)
|
|
52
54
|
return _defaultInstanceId;
|
|
53
|
-
if (_defaultInstanceFetched)
|
|
54
|
-
return '';
|
|
55
|
-
_defaultInstanceFetched = true;
|
|
56
55
|
if (!JWT)
|
|
57
56
|
return '';
|
|
57
|
+
// Cooldown: don't hammer the API on every tool call after a transient failure.
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000)
|
|
60
|
+
return '';
|
|
61
|
+
_defaultInstanceLastAttempt = now;
|
|
58
62
|
try {
|
|
59
63
|
const res = await fetch(`${API_URL}/api/v1/instances`, {
|
|
60
64
|
headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
|
|
@@ -63,13 +67,16 @@ async function resolveDefaultInstanceId() {
|
|
|
63
67
|
if (!res.ok)
|
|
64
68
|
return '';
|
|
65
69
|
const data = await res.json();
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
const instances = (data?.data ?? []);
|
|
71
|
+
// Prefer running, fall back to provisioning so the ID is resolved even during startup.
|
|
72
|
+
const best = instances.find(i => i.status === 'running') ?? instances.find(i => i.status === 'provisioning');
|
|
73
|
+
if (best) {
|
|
74
|
+
_defaultInstanceId = best.id;
|
|
75
|
+
_defaultInstanceLastAttempt = 0;
|
|
69
76
|
return _defaultInstanceId;
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
|
-
catch { /*
|
|
79
|
+
catch { /* transient error — will retry after cooldown */ }
|
|
73
80
|
return '';
|
|
74
81
|
}
|
|
75
82
|
let _deviceFlow = null;
|
|
@@ -132,16 +139,15 @@ async function pollDeviceFlow(flow) {
|
|
|
132
139
|
JWT = apiKey;
|
|
133
140
|
setEmbedJwt(apiKey); // keep embeddings.ts in sync
|
|
134
141
|
_deviceFlow = null;
|
|
135
|
-
// Auto-provision instance
|
|
136
|
-
|
|
142
|
+
// Auto-provision: find or create the user's brain instance.
|
|
143
|
+
_defaultInstanceLastAttempt = 0;
|
|
137
144
|
await resolveDefaultInstanceId();
|
|
138
145
|
if (!_defaultInstanceId) {
|
|
139
|
-
// Try auto-provision
|
|
140
146
|
try {
|
|
141
147
|
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
142
148
|
method: 'POST',
|
|
143
149
|
headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
|
|
144
|
-
signal: AbortSignal.timeout(
|
|
150
|
+
signal: AbortSignal.timeout(12000),
|
|
145
151
|
});
|
|
146
152
|
if (autoRes.ok) {
|
|
147
153
|
const body = await autoRes.json();
|
|
@@ -152,6 +158,38 @@ async function pollDeviceFlow(flow) {
|
|
|
152
158
|
}
|
|
153
159
|
catch { /* non-fatal */ }
|
|
154
160
|
}
|
|
161
|
+
// Give a provisioning instance a head-start so getConnection's wait is shorter.
|
|
162
|
+
// The instance is almost always ready by the time the user's tool re-enters.
|
|
163
|
+
if (_defaultInstanceId) {
|
|
164
|
+
try {
|
|
165
|
+
const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
166
|
+
headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
|
|
167
|
+
signal: AbortSignal.timeout(4000),
|
|
168
|
+
});
|
|
169
|
+
if (checkRes.ok) {
|
|
170
|
+
const checkInst = await checkRes.json();
|
|
171
|
+
if (checkInst.status === 'provisioning') {
|
|
172
|
+
// Wait up to 20s here so the re-entered tool call lands on a running instance.
|
|
173
|
+
const pDeadline = Date.now() + 20_000;
|
|
174
|
+
while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
|
|
175
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
176
|
+
const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
177
|
+
headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
|
|
178
|
+
signal: AbortSignal.timeout(4000),
|
|
179
|
+
}).catch(() => null);
|
|
180
|
+
if (rr?.ok) {
|
|
181
|
+
const ri = await rr.json();
|
|
182
|
+
checkInst.status = ri.status;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch { /* non-fatal — getConnection will handle the wait if still provisioning */ }
|
|
192
|
+
}
|
|
155
193
|
return 'done';
|
|
156
194
|
}
|
|
157
195
|
if (data.error === 'slow_down')
|
|
@@ -172,15 +210,42 @@ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp,
|
|
|
172
210
|
/** Reuse Redis connections across tool calls (keyed by instance_id). */
|
|
173
211
|
const pool = new Map();
|
|
174
212
|
async function getConnection(instance_id) {
|
|
213
|
+
if (!instance_id) {
|
|
214
|
+
throw new McpError(ErrorCode.InvalidRequest, 'No instance_id provided and no running instance could be resolved automatically.\n\n' +
|
|
215
|
+
'• Set CACHLY_BRAIN_INSTANCE_ID in your MCP config, or\n' +
|
|
216
|
+
'• Pass instance_id explicitly to the tool, or\n' +
|
|
217
|
+
'• Run `list_instances` to see your available instances.');
|
|
218
|
+
}
|
|
175
219
|
if (pool.has(instance_id))
|
|
176
220
|
return pool.get(instance_id);
|
|
177
|
-
|
|
221
|
+
// Fetch instance, waiting up to 25 s if it is still provisioning.
|
|
222
|
+
// This covers the zero-friction path: device-flow auth → auto-provision → first tool call
|
|
223
|
+
// all happen in quick succession and the instance isn't running yet.
|
|
224
|
+
let inst = await apiFetch(`/api/v1/instances/${instance_id}`);
|
|
225
|
+
if (inst.status === 'provisioning') {
|
|
226
|
+
const deadline = Date.now() + 25_000;
|
|
227
|
+
while (inst.status === 'provisioning' && Date.now() < deadline) {
|
|
228
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
229
|
+
inst = await apiFetch(`/api/v1/instances/${instance_id}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
178
232
|
if (inst.status !== 'running') {
|
|
179
|
-
|
|
180
|
-
|
|
233
|
+
// Telemetry: unreachable instance so the team can proactively investigate
|
|
234
|
+
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
|
|
237
|
+
body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
|
|
238
|
+
signal: AbortSignal.timeout(3000),
|
|
239
|
+
}).catch(() => { });
|
|
240
|
+
const hint = inst.status === 'provisioning'
|
|
241
|
+
? `⏳ Brain instance "${inst.name}" is still starting up.\n\nThis usually finishes within 30 seconds. Please try again in a moment.`
|
|
242
|
+
: `Brain instance "${inst.name}" is not reachable (status: ${inst.status}).\n\n` +
|
|
243
|
+
`• View your instance at: https://cachly.dev/instances\n` +
|
|
244
|
+
`• Run \`get_api_status\` for a full diagnostic.`;
|
|
245
|
+
throw new McpError(ErrorCode.InvalidRequest, hint);
|
|
181
246
|
}
|
|
182
247
|
if (!inst.host || !inst.port) {
|
|
183
|
-
throw new McpError(ErrorCode.InternalError, `Instance "${inst.name}" has no host/port
|
|
248
|
+
throw new McpError(ErrorCode.InternalError, `Instance "${inst.name}" is running but has no host/port — contact support@cachly.dev.`);
|
|
184
249
|
}
|
|
185
250
|
// Fetch connection details (includes password) from the dedicated endpoint.
|
|
186
251
|
let password;
|
|
@@ -191,57 +256,44 @@ async function getConnection(instance_id) {
|
|
|
191
256
|
tlsEnabled = conn.tls_enabled !== false;
|
|
192
257
|
}
|
|
193
258
|
catch {
|
|
194
|
-
// Fallback:
|
|
259
|
+
// Fallback: use TLS flag from instance metadata
|
|
195
260
|
}
|
|
196
261
|
const client = new Redis({
|
|
197
262
|
host: inst.host,
|
|
198
263
|
port: inst.port,
|
|
199
264
|
password: password || undefined,
|
|
200
265
|
...(tlsEnabled ? { tls: {} } : {}),
|
|
201
|
-
lazyConnect: true,
|
|
266
|
+
lazyConnect: true,
|
|
202
267
|
enableReadyCheck: true,
|
|
203
|
-
connectTimeout:
|
|
268
|
+
connectTimeout: 8000,
|
|
204
269
|
retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
|
|
205
270
|
});
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// Connect explicitly so we can catch connection errors as a proper rejected
|
|
210
|
-
// Promise instead of an unhandled 'error' event that would kill the process.
|
|
271
|
+
const evict = () => pool.delete(instance_id);
|
|
272
|
+
client.on('error', evict);
|
|
273
|
+
client.on('end', evict);
|
|
211
274
|
try {
|
|
212
275
|
await client.connect();
|
|
213
276
|
}
|
|
214
277
|
catch (err) {
|
|
215
278
|
client.disconnect();
|
|
216
279
|
const msg = err instanceof Error ? err.message : String(err);
|
|
217
|
-
|
|
280
|
+
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
|
|
283
|
+
body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
|
|
284
|
+
signal: AbortSignal.timeout(3000),
|
|
285
|
+
}).catch(() => { });
|
|
286
|
+
throw new McpError(ErrorCode.InternalError, `Could not reach Brain instance "${inst.name}" (${inst.host}:${inst.port}).\n\n` +
|
|
287
|
+
`• Check your network connection\n` +
|
|
288
|
+
`• Run \`get_api_status\` to verify instance status\n` +
|
|
289
|
+
`• Technical detail: ${msg}`);
|
|
218
290
|
}
|
|
219
291
|
pool.set(instance_id, client);
|
|
220
292
|
return client;
|
|
221
293
|
}
|
|
222
294
|
// ── API helper ────────────────────────────────────────────────────────────────
|
|
223
|
-
function jwtExpiryMs(token) {
|
|
224
|
-
try {
|
|
225
|
-
const parts = token.split('.');
|
|
226
|
-
if (parts.length !== 3)
|
|
227
|
-
return null;
|
|
228
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
|
229
|
-
return payload.exp ? payload.exp * 1000 : null;
|
|
230
|
-
}
|
|
231
|
-
catch {
|
|
232
|
-
return null;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
295
|
async function apiFetch(path, options = {}) {
|
|
236
|
-
|
|
237
|
-
throw new McpError(ErrorCode.InvalidRequest, '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>');
|
|
238
|
-
}
|
|
239
|
-
// Detect token expiry before making the network call — gives a clearer error
|
|
240
|
-
const expMs = jwtExpiryMs(JWT);
|
|
241
|
-
if (expMs !== null && expMs < Date.now()) {
|
|
242
|
-
const expiredAt = new Date(expMs).toISOString();
|
|
243
|
-
throw new McpError(ErrorCode.InvalidRequest, `CACHLY_JWT expired at ${expiredAt}.\n\nGet a fresh token at https://cachly.dev/setup-ai and update CACHLY_JWT in your MCP config.`);
|
|
244
|
-
}
|
|
296
|
+
checkJwt(JWT);
|
|
245
297
|
const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
|
|
246
298
|
const res = await fetch(`${API_URL}${path}`, {
|
|
247
299
|
...options,
|
|
@@ -255,13 +307,7 @@ async function apiFetch(path, options = {}) {
|
|
|
255
307
|
if (!res.ok) {
|
|
256
308
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
257
309
|
const detail = body.error ?? res.statusText;
|
|
258
|
-
|
|
259
|
-
throw new McpError(ErrorCode.InvalidRequest, `Authentication failed (401): ${detail}\n\nYour CACHLY_JWT may be expired or invalid. Get a fresh token at https://cachly.dev/setup-ai`);
|
|
260
|
-
}
|
|
261
|
-
if (res.status === 403) {
|
|
262
|
-
throw new McpError(ErrorCode.InvalidRequest, `Access denied (403): ${detail}\n\nCheck that your CACHLY_JWT belongs to an account with access to this resource.`);
|
|
263
|
-
}
|
|
264
|
-
throw new McpError(ErrorCode.InternalError, `cachly API error ${res.status}: ${detail}`);
|
|
310
|
+
handleApiError(res.status, detail);
|
|
265
311
|
}
|
|
266
312
|
if (res.status === 204)
|
|
267
313
|
return undefined;
|
|
@@ -279,48 +325,6 @@ import { handleFedbrainTool } from './handlers/fedbrain.js';
|
|
|
279
325
|
// ── Tools (imported from tools.ts) ─────────────────────────────────────────
|
|
280
326
|
import { TOOLS } from './tools.js';
|
|
281
327
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
|
282
|
-
// ── Tool-call analytics (opt-out: CACHLY_NO_TELEMETRY=1) ────────────────────
|
|
283
|
-
// In-memory counters — exposed via /stats on HTTP mode
|
|
284
|
-
const _toolCallCounts = {};
|
|
285
|
-
let _toolCallTotal = 0;
|
|
286
|
-
let _toolCallAuthedTotal = 0;
|
|
287
|
-
async function trackToolCall(toolName, authed, durationMs) {
|
|
288
|
-
if (process.env.CACHLY_NO_TELEMETRY === '1')
|
|
289
|
-
return;
|
|
290
|
-
// Increment in-memory counters
|
|
291
|
-
_toolCallTotal++;
|
|
292
|
-
if (authed)
|
|
293
|
-
_toolCallAuthedTotal++;
|
|
294
|
-
_toolCallCounts[toolName] = (_toolCallCounts[toolName] ?? 0) + 1;
|
|
295
|
-
// Structured JSON to stdout — searchable via: docker logs cachly-mcp-http-1 | jq
|
|
296
|
-
process.stdout.write(JSON.stringify({
|
|
297
|
-
ts: new Date().toISOString(),
|
|
298
|
-
event: 'tool_call',
|
|
299
|
-
tool: toolName,
|
|
300
|
-
authed,
|
|
301
|
-
ms: durationMs,
|
|
302
|
-
v: CURRENT_VERSION,
|
|
303
|
-
}) + '\n');
|
|
304
|
-
// Plausible custom event (fire-and-forget, never blocks user)
|
|
305
|
-
try {
|
|
306
|
-
await fetch('https://analytics.cachly.dev/api/event', {
|
|
307
|
-
method: 'POST',
|
|
308
|
-
headers: {
|
|
309
|
-
'Content-Type': 'application/json',
|
|
310
|
-
'User-Agent': `cachly-mcp/${CURRENT_VERSION}`,
|
|
311
|
-
'X-Forwarded-For': '127.0.0.1',
|
|
312
|
-
},
|
|
313
|
-
body: JSON.stringify({
|
|
314
|
-
domain: 'mcp.cachly.dev',
|
|
315
|
-
name: 'tool_call',
|
|
316
|
-
url: `https://mcp.cachly.dev/mcp/${toolName}`,
|
|
317
|
-
props: { tool: toolName, authed: String(authed) },
|
|
318
|
-
}),
|
|
319
|
-
signal: AbortSignal.timeout(3000),
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
catch { /* fire-and-forget */ }
|
|
323
|
-
}
|
|
324
328
|
// Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
|
|
325
329
|
let _telemetryPingSent = false;
|
|
326
330
|
async function sendAnonymousTelemetry(toolName) {
|
|
@@ -346,61 +350,6 @@ async function sendAnonymousTelemetry(toolName) {
|
|
|
346
350
|
catch { /* fire-and-forget, never block the user */ }
|
|
347
351
|
}
|
|
348
352
|
async function handleTool(name, args) {
|
|
349
|
-
// ── sign_in: explicit device-flow trigger — works even without JWT ────────
|
|
350
|
-
if (name === 'sign_in') {
|
|
351
|
-
if (JWT) {
|
|
352
|
-
return '✅ **Already signed in.** Your cachly AI Brain is ready — all 63 tools available.';
|
|
353
|
-
}
|
|
354
|
-
// Poll if flow already in progress
|
|
355
|
-
if (_deviceFlow) {
|
|
356
|
-
const result = await pollDeviceFlow(_deviceFlow);
|
|
357
|
-
if (result === 'done') {
|
|
358
|
-
return [
|
|
359
|
-
'✅ **Sign-in complete!** Your cachly AI Brain is ready.',
|
|
360
|
-
'',
|
|
361
|
-
'All 63 brain tools are now available. Start with `session_start` to load context from your last session.',
|
|
362
|
-
].join('\n');
|
|
363
|
-
}
|
|
364
|
-
if (result === 'expired') {
|
|
365
|
-
_deviceFlow = null;
|
|
366
|
-
// fall through to start a new flow
|
|
367
|
-
}
|
|
368
|
-
else {
|
|
369
|
-
return [
|
|
370
|
-
'⏳ **Waiting for you to approve in the browser...**',
|
|
371
|
-
'',
|
|
372
|
-
`Sign in at: **${_deviceFlow.verifyUrl}**`,
|
|
373
|
-
`Code: **${_deviceFlow.userCode}**`,
|
|
374
|
-
'',
|
|
375
|
-
'After approving, call `sign_in` again — it will complete automatically.',
|
|
376
|
-
].join('\n');
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
const flow = await startDeviceFlow();
|
|
380
|
-
if (flow) {
|
|
381
|
-
_deviceFlow = flow;
|
|
382
|
-
return [
|
|
383
|
-
'🧠 **cachly AI Brain — One-click sign in**',
|
|
384
|
-
'',
|
|
385
|
-
'1. Open this URL (it may open automatically):',
|
|
386
|
-
` **${flow.verifyUrl}**`,
|
|
387
|
-
'',
|
|
388
|
-
`2. If prompted for a code, enter: **${flow.userCode}**`,
|
|
389
|
-
'',
|
|
390
|
-
'3. After approving in your browser, call `sign_in` again — it will complete automatically.',
|
|
391
|
-
'',
|
|
392
|
-
'✨ Free tier: 1 Brain instance · persistent memory · 63 MCP tools · no credit card required.',
|
|
393
|
-
].join('\n');
|
|
394
|
-
}
|
|
395
|
-
return [
|
|
396
|
-
'🧠 **cachly AI Brain — Setup required**',
|
|
397
|
-
'',
|
|
398
|
-
'Run once in your terminal:',
|
|
399
|
-
' `npx @cachly-dev/mcp-server@latest setup`',
|
|
400
|
-
'',
|
|
401
|
-
'Or sign up at: https://cachly.dev/setup-ai',
|
|
402
|
-
].join('\n');
|
|
403
|
-
}
|
|
404
353
|
// Guard: if no JWT, return actionable onboarding message instead of HTTP 401
|
|
405
354
|
if (!JWT) {
|
|
406
355
|
void sendAnonymousTelemetry(name);
|
|
@@ -459,9 +408,6 @@ async function handleTool(name, args) {
|
|
|
459
408
|
'✨ Free tier includes: 1 Brain instance, persistent memory, semantic search.',
|
|
460
409
|
].join('\n');
|
|
461
410
|
}
|
|
462
|
-
// Track authenticated tool call (fire-and-forget)
|
|
463
|
-
const _trackStart = Date.now();
|
|
464
|
-
void (async () => { await trackToolCall(name, true, Date.now() - _trackStart); })();
|
|
465
411
|
// Auto-resolve instance_id from env / API when not provided in args
|
|
466
412
|
if (!args.instance_id) {
|
|
467
413
|
const defaultId = await resolveDefaultInstanceId();
|
|
@@ -552,6 +498,49 @@ async function handleTool(name, args) {
|
|
|
552
498
|
catch {
|
|
553
499
|
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
554
500
|
}
|
|
501
|
+
// List instances + connectivity
|
|
502
|
+
let instanceInfo = '';
|
|
503
|
+
try {
|
|
504
|
+
const listRes = await apiFetch('/api/v1/instances');
|
|
505
|
+
const instances = listRes.data ?? [];
|
|
506
|
+
if (instances.length === 0) {
|
|
507
|
+
instanceInfo = '\n\n🧠 **Brain Instances:** none — create one at https://cachly.dev/instances';
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
const lines = ['\n\n🧠 **Brain Instances:**'];
|
|
511
|
+
for (const inst of instances) {
|
|
512
|
+
const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
|
|
513
|
+
const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
|
|
514
|
+
lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
|
|
515
|
+
if (inst.status === 'running' && inst.host) {
|
|
516
|
+
// Try a quick ping on the cached connection
|
|
517
|
+
const pooled = pool.get(inst.id);
|
|
518
|
+
if (pooled) {
|
|
519
|
+
try {
|
|
520
|
+
await pooled.ping();
|
|
521
|
+
lines.push(` 💓 Redis: connected`);
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
lines.push(` ⚠️ Redis: pooled but ping failed — will reconnect on next use`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
lines.push(` 💤 Redis: not connected yet (connects on first tool use)`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else if (inst.status === 'provisioning') {
|
|
532
|
+
lines.push(` ⏳ Still provisioning — check back in ~30 s`);
|
|
533
|
+
}
|
|
534
|
+
else if (inst.status !== 'running') {
|
|
535
|
+
lines.push(` ❌ Not reachable (status: ${inst.status})`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
instanceInfo = lines.join('\n');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
instanceInfo = `\n\n🧠 **Brain Instances:** could not fetch — ${e.message}`;
|
|
543
|
+
}
|
|
555
544
|
return [
|
|
556
545
|
`📡 **cachly API Status**`,
|
|
557
546
|
``,
|
|
@@ -560,6 +549,7 @@ async function handleTool(name, args) {
|
|
|
560
549
|
``,
|
|
561
550
|
`🔑 **Auth:**`,
|
|
562
551
|
authInfo,
|
|
552
|
+
instanceInfo,
|
|
563
553
|
].join('\n');
|
|
564
554
|
}
|
|
565
555
|
default:
|
|
@@ -567,15 +557,7 @@ async function handleTool(name, args) {
|
|
|
567
557
|
}
|
|
568
558
|
}
|
|
569
559
|
const server = new Server({ name: 'cachly-mcp', version: CURRENT_VERSION }, { capabilities: { tools: {} } });
|
|
570
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
571
|
-
if (!JWT) {
|
|
572
|
-
// Surface sign_in as the only prominent tool when not authenticated,
|
|
573
|
-
// so AI assistants proactively call it before attempting any brain tool.
|
|
574
|
-
const signInTool = TOOLS.find(t => t.name === 'sign_in');
|
|
575
|
-
return { tools: signInTool ? [signInTool] : TOOLS };
|
|
576
|
-
}
|
|
577
|
-
return { tools: TOOLS };
|
|
578
|
-
});
|
|
560
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
579
561
|
// ── Auto-session management ───────────────────────────────────────────────────
|
|
580
562
|
// Transparently starts a Brain session on the first tool call of a process
|
|
581
563
|
// and saves a session_end summary on SIGTERM/exit so users never have to call
|
|
@@ -602,7 +584,9 @@ async function autoStartSession(instanceId) {
|
|
|
602
584
|
await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
|
|
603
585
|
}
|
|
604
586
|
catch { /* non-fatal — session tracking is a best-effort feature */ }
|
|
605
|
-
|
|
587
|
+
finally {
|
|
588
|
+
_autoSessionStarting = null;
|
|
589
|
+
}
|
|
606
590
|
})();
|
|
607
591
|
await _autoSessionStarting;
|
|
608
592
|
// Auto-index the project if it hasn't been indexed in the last 24h.
|
|
@@ -650,7 +634,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
650
634
|
// Skip session management tools to avoid recursion.
|
|
651
635
|
const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
|
|
652
636
|
if (!sessionTools.has(name) && !_autoSessionStarted && !_autoSessionStarting) {
|
|
653
|
-
const instanceId = args?.instance_id
|
|
637
|
+
const instanceId = args?.instance_id
|
|
638
|
+
|| _defaultInstanceId
|
|
639
|
+
|| process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
654
640
|
if (instanceId) {
|
|
655
641
|
await autoStartSession(instanceId).catch(() => undefined);
|
|
656
642
|
}
|
|
@@ -740,54 +726,47 @@ function buildMcpConfig(apiKey, instanceId, editor) {
|
|
|
740
726
|
}
|
|
741
727
|
function buildClaudeMdBlock(instanceId) {
|
|
742
728
|
return `${CLAUDE_MD_MARKER_START}
|
|
743
|
-
## Cachly AI Brain
|
|
729
|
+
## Cachly AI Brain — Always Active
|
|
744
730
|
|
|
745
|
-
The
|
|
746
|
-
Your instance ID: \`${instanceId}\`
|
|
731
|
+
The Brain MCP server runs automatically. Instance: \`${instanceId}\`
|
|
747
732
|
|
|
748
|
-
|
|
749
|
-
> 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.
|
|
733
|
+
Sessions start and end automatically — never call \`session_start\` or \`session_end\`.
|
|
750
734
|
|
|
751
735
|
---
|
|
752
736
|
|
|
753
|
-
###
|
|
737
|
+
### BEFORE every task (mandatory — no exceptions):
|
|
738
|
+
\`\`\`
|
|
739
|
+
smart_recall(instance_id="${instanceId}", query="<what you are about to do>")
|
|
740
|
+
\`\`\`
|
|
741
|
+
Report any relevant lessons found to the user before proceeding.
|
|
742
|
+
|
|
743
|
+
### BEFORE editing any file (mandatory):
|
|
744
|
+
\`\`\`
|
|
745
|
+
remember_context(instance_id="${instanceId}", key="wip:<filename>", content="EDITING: <file:line> — <goal>", ttl=86400)
|
|
746
|
+
\`\`\`
|
|
747
|
+
When done: update content to \`"DONE: <file> — <summary>"\`.
|
|
748
|
+
|
|
749
|
+
### AFTER every fix, deploy, or discovery (mandatory):
|
|
754
750
|
\`\`\`
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
content = "EDITING: <file:line> — goal: <what you are implementing>",
|
|
759
|
-
category = "bug",
|
|
760
|
-
ttl = 86400,
|
|
761
|
-
)
|
|
751
|
+
learn_from_attempts(instance_id="${instanceId}", topic="<category:keyword>", outcome="success"|"failure",
|
|
752
|
+
what_worked="...", what_failed="...", severity="critical"|"major"|"minor",
|
|
753
|
+
file_paths=["..."], commands=["..."], tags=["..."])
|
|
762
754
|
\`\`\`
|
|
763
|
-
After the edit is complete, update \`content\` to \`"DONE: <file> — <what was completed>"\`.
|
|
764
755
|
|
|
765
|
-
###
|
|
756
|
+
### WHEN debugging any error (mandatory before grepping/reading files):
|
|
766
757
|
\`\`\`
|
|
767
|
-
|
|
768
|
-
instance_id = "${instanceId}",
|
|
769
|
-
topic = "category:keyword",
|
|
770
|
-
outcome = "success",
|
|
771
|
-
what_worked = "...",
|
|
772
|
-
what_failed = "...",
|
|
773
|
-
severity = "critical" | "major" | "minor",
|
|
774
|
-
file_paths = ["path/to/file"],
|
|
775
|
-
commands = ["the command that worked"],
|
|
776
|
-
tags = ["tag1"],
|
|
777
|
-
)
|
|
758
|
+
causal_trace(instance_id="${instanceId}", problem="<exact error or symptom>")
|
|
778
759
|
\`\`\`
|
|
760
|
+
If a known fix exists in the Brain, use it directly. Skip research if Brain has the answer.
|
|
779
761
|
|
|
780
|
-
###
|
|
762
|
+
### BEFORE any risky operation (deploy, migration, dependency upgrade):
|
|
781
763
|
\`\`\`
|
|
782
|
-
|
|
783
|
-
instance_id = "${instanceId}",
|
|
784
|
-
query = "<describe what you are about to do>",
|
|
785
|
-
)
|
|
764
|
+
brain_predict(instance_id="${instanceId}", context="<what you are about to do>")
|
|
786
765
|
\`\`\`
|
|
766
|
+
Report predicted failure risks to the user before proceeding.
|
|
787
767
|
|
|
788
768
|
### Half-finished code rule:
|
|
789
769
|
Never commit code that does not compile. Run \`tsc --noEmit\` / \`go build ./...\` before every commit.
|
|
790
|
-
If a session ends mid-task, save the WIP-registry entry so the next session picks up exactly where you left off.
|
|
791
770
|
${CLAUDE_MD_MARKER_END}`;
|
|
792
771
|
}
|
|
793
772
|
async function writeClaudeMd(projectDir, instanceId) {
|
|
@@ -884,7 +863,7 @@ if (process.argv[2] === 'init') {
|
|
|
884
863
|
// ── CLI: cachly setup (interactive — no flags required) ───────────────────────
|
|
885
864
|
// Usage: npx @cachly-dev/mcp-server setup
|
|
886
865
|
if (process.argv[2] === 'setup') {
|
|
887
|
-
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
866
|
+
const { writeFile, mkdir, readFile } = await import('node:fs/promises');
|
|
888
867
|
const { existsSync } = await import('node:fs');
|
|
889
868
|
const { resolve, dirname } = await import('node:path');
|
|
890
869
|
const { createInterface } = await import('node:readline');
|
|
@@ -1133,7 +1112,7 @@ if (process.argv[2] === 'setup') {
|
|
|
1133
1112
|
// Auto-configure all detected editors — no prompt needed.
|
|
1134
1113
|
const editorsToSetup = detected;
|
|
1135
1114
|
console.log(`Step 3: Configuring for: ${editorsToSetup.map(editorLabel).join(', ')}\n`);
|
|
1136
|
-
// ── Step 4: Write editor configs
|
|
1115
|
+
// ── Step 4: Write editor configs (project-level) ─────────────────────────
|
|
1137
1116
|
for (const editor of editorsToSetup) {
|
|
1138
1117
|
const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
|
|
1139
1118
|
const configPath = resolve(cwd, configFile);
|
|
@@ -1141,10 +1120,54 @@ if (process.argv[2] === 'setup') {
|
|
|
1141
1120
|
await writeFile(configPath, buildMcpConfig(token, instance.id, editor), 'utf-8');
|
|
1142
1121
|
console.log(`✅ Written: ${configFile}`);
|
|
1143
1122
|
}
|
|
1123
|
+
// ── Step 4b: Write global Claude Code config (~/.claude/mcp.json) ─────────
|
|
1124
|
+
// Merges cachly into the existing global config so it works in every project,
|
|
1125
|
+
// without removing other MCP servers the user may have configured.
|
|
1126
|
+
const globalClaudePath = resolve(home, '.claude', 'mcp.json');
|
|
1127
|
+
try {
|
|
1128
|
+
await mkdir(dirname(globalClaudePath), { recursive: true });
|
|
1129
|
+
let globalConfig = {};
|
|
1130
|
+
if (existsSync(globalClaudePath)) {
|
|
1131
|
+
try {
|
|
1132
|
+
globalConfig = JSON.parse(await readFile(globalClaudePath, 'utf-8'));
|
|
1133
|
+
}
|
|
1134
|
+
catch { /* corrupt — start fresh */ }
|
|
1135
|
+
}
|
|
1136
|
+
globalConfig.mcpServers ??= {};
|
|
1137
|
+
globalConfig.mcpServers['cachly'] = {
|
|
1138
|
+
command: 'npx',
|
|
1139
|
+
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
1140
|
+
env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: token, CACHLY_BRAIN_INSTANCE_ID: instance.id },
|
|
1141
|
+
};
|
|
1142
|
+
await writeFile(globalClaudePath, JSON.stringify(globalConfig, null, 2), 'utf-8');
|
|
1143
|
+
console.log(`✅ Written: ~/.claude/mcp.json (global — works in every project)`);
|
|
1144
|
+
}
|
|
1145
|
+
catch (e) {
|
|
1146
|
+
console.log(`⚠️ Could not write ~/.claude/mcp.json: ${e.message}`);
|
|
1147
|
+
}
|
|
1144
1148
|
// ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
|
|
1145
1149
|
const mdResult = await writeClaudeMd(cwd, instance.id);
|
|
1146
1150
|
const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
|
|
1147
1151
|
console.log(`${mdLabel}: CLAUDE.md\n`);
|
|
1152
|
+
// ── Step 5b: Bootstrap Brain from git history ─────────────────────────────
|
|
1153
|
+
// Pre-populates the Brain with real lessons so the first session_start shows
|
|
1154
|
+
// actual project knowledge instead of an empty brain.
|
|
1155
|
+
if (existsSync(resolve(cwd, '.git'))) {
|
|
1156
|
+
process.stdout.write('⏳ Bootstrapping Brain from git history (~10s)...');
|
|
1157
|
+
try {
|
|
1158
|
+
JWT = token; // set global JWT so handleTool can authenticate
|
|
1159
|
+
const gitResult = await handleTool('brain_from_git', {
|
|
1160
|
+
instance_id: instance.id,
|
|
1161
|
+
dir: cwd,
|
|
1162
|
+
max_commits: 100,
|
|
1163
|
+
});
|
|
1164
|
+
const match = gitResult.match(/(\d+) lesson/);
|
|
1165
|
+
console.log(` ✓ ${match ? match[1] + ' lessons' : 'done'} extracted from git history`);
|
|
1166
|
+
}
|
|
1167
|
+
catch (e) {
|
|
1168
|
+
console.log(` (skipped: ${e.message.slice(0, 80)})`);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1148
1171
|
// ── Step 6: Show Brain health (Aha moment) ────────────────────────────────
|
|
1149
1172
|
// Fetch brain health from the API to show the user what their agent will see.
|
|
1150
1173
|
process.stdout.write('\n⏳ Fetching your Brain health preview...');
|
|
@@ -1178,6 +1201,36 @@ if (process.argv[2] === 'setup') {
|
|
|
1178
1201
|
console.log('│ • A session summary for next time │');
|
|
1179
1202
|
}
|
|
1180
1203
|
console.log('└──────────────────────────────────────────────────────┘');
|
|
1204
|
+
// ── Step 6b: brain_predict preview (only if we have real lessons) ───────
|
|
1205
|
+
if (lessons >= 5) {
|
|
1206
|
+
process.stdout.write('\n⏳ Running predictive risk scan...');
|
|
1207
|
+
try {
|
|
1208
|
+
const predictResult = await handleTool('brain_predict', {
|
|
1209
|
+
instance_id: instance.id,
|
|
1210
|
+
context: 'starting a new development session',
|
|
1211
|
+
});
|
|
1212
|
+
const predictLines = predictResult
|
|
1213
|
+
.split('\n')
|
|
1214
|
+
.filter((l) => l.trim())
|
|
1215
|
+
.slice(0, 6);
|
|
1216
|
+
if (predictLines.length > 0) {
|
|
1217
|
+
console.log(' ✓\n');
|
|
1218
|
+
console.log('┌──────────────────────────────────────────────────────┐');
|
|
1219
|
+
console.log('│ 🔮 Brain Risk Preview (brain_predict) │');
|
|
1220
|
+
console.log('├──────────────────────────────────────────────────────┤');
|
|
1221
|
+
for (const pl of predictLines) {
|
|
1222
|
+
console.log(`│ ${pl.slice(0, 51).padEnd(51)} │`);
|
|
1223
|
+
}
|
|
1224
|
+
console.log('└──────────────────────────────────────────────────────┘');
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
console.log(' (no risks found — clean slate!)');
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
catch {
|
|
1231
|
+
console.log(' (skipped)');
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1181
1234
|
}
|
|
1182
1235
|
else {
|
|
1183
1236
|
console.log(' (skipped)');
|
|
@@ -1350,7 +1403,6 @@ if (httpPort) {
|
|
|
1350
1403
|
res.end(JSON.stringify({
|
|
1351
1404
|
status: 'ok',
|
|
1352
1405
|
version: CURRENT_VERSION,
|
|
1353
|
-
toolCalls: { total: _toolCallTotal, authed: _toolCallAuthedTotal },
|
|
1354
1406
|
zeroResults: {
|
|
1355
1407
|
total: zeroResultsTotal,
|
|
1356
1408
|
last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
|
|
@@ -1358,23 +1410,6 @@ if (httpPort) {
|
|
|
1358
1410
|
}));
|
|
1359
1411
|
return;
|
|
1360
1412
|
}
|
|
1361
|
-
if (url.pathname === '/stats') {
|
|
1362
|
-
// Top tools sorted by call count
|
|
1363
|
-
const top = Object.entries(_toolCallCounts)
|
|
1364
|
-
.sort(([, a], [, b]) => b - a)
|
|
1365
|
-
.slice(0, 20)
|
|
1366
|
-
.map(([tool, count]) => ({ tool, count }));
|
|
1367
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1368
|
-
res.end(JSON.stringify({
|
|
1369
|
-
version: CURRENT_VERSION,
|
|
1370
|
-
total: _toolCallTotal,
|
|
1371
|
-
authed: _toolCallAuthedTotal,
|
|
1372
|
-
unauthenticated: _toolCallTotal - _toolCallAuthedTotal,
|
|
1373
|
-
topTools: top,
|
|
1374
|
-
uptimeSec: Math.floor(process.uptime()),
|
|
1375
|
-
}));
|
|
1376
|
-
return;
|
|
1377
|
-
}
|
|
1378
1413
|
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
1379
1414
|
transport.handleRequest(req, res);
|
|
1380
1415
|
return;
|