@cachly-dev/mcp-server 0.10.46 → 0.10.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -5
- package/dist/__tests__/brain-flow.test.js +2 -143
- package/dist/__tests__/brain-flow.test.js.map +1 -1
- package/dist/__tests__/brain-predict-failures.test.d.ts +17 -0
- package/dist/__tests__/brain-predict-failures.test.js +339 -0
- package/dist/__tests__/brain-predict-failures.test.js.map +1 -0
- package/dist/__tests__/handlers-unit.test.js +3 -1
- package/dist/__tests__/handlers-unit.test.js.map +1 -1
- package/dist/handlers/brain.js +22 -121
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +6 -5
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/roadmap.js +1 -1
- package/dist/handlers/roadmap.js.map +1 -1
- package/dist/handlers/team.js +1 -1
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +201 -42
- package/dist/index.js.map +1 -1
- package/dist/search.js +3 -3
- package/dist/search.js.map +1 -1
- package/package.json +2 -1
- package/smithery.yaml +8 -21
- package/src/__tests__/brain-flow.test.ts +2 -176
- package/src/__tests__/brain-predict-failures.test.ts +430 -0
- package/src/__tests__/handlers-unit.test.ts +2 -1
- package/src/handlers/brain.ts +23 -125
- package/src/handlers/fedbrain.ts +6 -5
- package/src/handlers/roadmap.ts +1 -1
- package/src/handlers/team.ts +1 -1
- package/src/index.ts +204 -41
- package/src/search.ts +3 -3
package/dist/index.js
CHANGED
|
@@ -45,11 +45,15 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
45
45
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
46
46
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
47
47
|
import { Redis } from 'ioredis';
|
|
48
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
48
49
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
49
50
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
50
51
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
51
52
|
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
52
|
-
const CURRENT_VERSION = '0.10.
|
|
53
|
+
const CURRENT_VERSION = '0.10.47';
|
|
54
|
+
const reqCtx = new AsyncLocalStorage();
|
|
55
|
+
function getJWT() { return reqCtx.getStore()?.jwt || JWT; }
|
|
56
|
+
function getDefaultInstanceId() { return reqCtx.getStore()?.instanceId || _defaultInstanceId; }
|
|
53
57
|
// ── Default Instance Resolution (for Smithery & single-credential setups) ────
|
|
54
58
|
// When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
|
|
55
59
|
// When neither is set, we auto-fetch the first running instance once per process.
|
|
@@ -57,9 +61,13 @@ let _defaultInstanceId = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
|
|
|
57
61
|
// Timestamp of last failed fetch — retries after 30 s (not permanently blocked).
|
|
58
62
|
let _defaultInstanceLastAttempt = 0;
|
|
59
63
|
async function resolveDefaultInstanceId() {
|
|
64
|
+
// In an HTTP request scope the instance ID is fixed by the caller's header.
|
|
65
|
+
const scopedId = reqCtx.getStore()?.instanceId;
|
|
66
|
+
if (scopedId)
|
|
67
|
+
return scopedId;
|
|
60
68
|
if (_defaultInstanceId)
|
|
61
69
|
return _defaultInstanceId;
|
|
62
|
-
if (!
|
|
70
|
+
if (!getJWT())
|
|
63
71
|
return '';
|
|
64
72
|
// Cooldown: don't hammer the API on every tool call after a transient failure.
|
|
65
73
|
const now = Date.now();
|
|
@@ -68,7 +76,7 @@ async function resolveDefaultInstanceId() {
|
|
|
68
76
|
_defaultInstanceLastAttempt = now;
|
|
69
77
|
try {
|
|
70
78
|
const res = await fetch(`${API_URL}/api/v1/instances`, {
|
|
71
|
-
headers: { Authorization: `Bearer ${
|
|
79
|
+
headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
|
|
72
80
|
signal: AbortSignal.timeout(5000),
|
|
73
81
|
});
|
|
74
82
|
if (!res.ok)
|
|
@@ -154,7 +162,7 @@ async function pollDeviceFlow(flow) {
|
|
|
154
162
|
try {
|
|
155
163
|
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
156
164
|
method: 'POST',
|
|
157
|
-
headers: { Authorization: `Bearer ${
|
|
165
|
+
headers: { Authorization: `Bearer ${getJWT()}`, 'Content-Type': 'application/json' },
|
|
158
166
|
signal: AbortSignal.timeout(12000),
|
|
159
167
|
});
|
|
160
168
|
if (autoRes.ok) {
|
|
@@ -167,7 +175,7 @@ async function pollDeviceFlow(flow) {
|
|
|
167
175
|
// Log so telemetry can detect free-tier auto-provision failures
|
|
168
176
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
169
177
|
method: 'POST',
|
|
170
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${
|
|
178
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getJWT()}` },
|
|
171
179
|
body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
|
|
172
180
|
signal: AbortSignal.timeout(3000),
|
|
173
181
|
}).catch(() => { });
|
|
@@ -180,7 +188,7 @@ async function pollDeviceFlow(flow) {
|
|
|
180
188
|
if (_defaultInstanceId) {
|
|
181
189
|
try {
|
|
182
190
|
const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
183
|
-
headers: { Authorization: `Bearer ${
|
|
191
|
+
headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
|
|
184
192
|
signal: AbortSignal.timeout(4000),
|
|
185
193
|
});
|
|
186
194
|
if (checkRes.ok) {
|
|
@@ -191,7 +199,7 @@ async function pollDeviceFlow(flow) {
|
|
|
191
199
|
while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
|
|
192
200
|
await new Promise(r => setTimeout(r, 3000));
|
|
193
201
|
const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
194
|
-
headers: { Authorization: `Bearer ${
|
|
202
|
+
headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
|
|
195
203
|
signal: AbortSignal.timeout(4000),
|
|
196
204
|
}).catch(() => null);
|
|
197
205
|
if (rr?.ok) {
|
|
@@ -233,8 +241,19 @@ async function getConnection(instance_id) {
|
|
|
233
241
|
'• Pass instance_id explicitly to the tool, or\n' +
|
|
234
242
|
'• Run `list_instances` to see your available instances.');
|
|
235
243
|
}
|
|
236
|
-
if (pool.has(instance_id))
|
|
237
|
-
|
|
244
|
+
if (pool.has(instance_id)) {
|
|
245
|
+
const cached = pool.get(instance_id);
|
|
246
|
+
// Verify the pooled connection is still alive before reusing it.
|
|
247
|
+
// ioredis 'error' eviction is async — a connection can be in the pool
|
|
248
|
+
// but already internally broken (status 'reconnecting' or 'end').
|
|
249
|
+
const status = cached.status;
|
|
250
|
+
if (status === 'ready' || status === 'connect') {
|
|
251
|
+
return cached;
|
|
252
|
+
}
|
|
253
|
+
// Stale — evict and fall through to reconnect.
|
|
254
|
+
pool.delete(instance_id);
|
|
255
|
+
cached.disconnect();
|
|
256
|
+
}
|
|
238
257
|
// Fetch instance, waiting up to 25 s if it is still provisioning.
|
|
239
258
|
// This covers the zero-friction path: device-flow auth → auto-provision → first tool call
|
|
240
259
|
// all happen in quick succession and the instance isn't running yet.
|
|
@@ -250,7 +269,7 @@ async function getConnection(instance_id) {
|
|
|
250
269
|
// Telemetry: unreachable instance so the team can proactively investigate
|
|
251
270
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
252
271
|
method: 'POST',
|
|
253
|
-
headers: { 'Content-Type': 'application/json', ...(
|
|
272
|
+
headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
|
|
254
273
|
body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
|
|
255
274
|
signal: AbortSignal.timeout(3000),
|
|
256
275
|
}).catch(() => { });
|
|
@@ -311,15 +330,45 @@ async function getConnection(instance_id) {
|
|
|
311
330
|
const evict = () => pool.delete(instance_id);
|
|
312
331
|
client.on('error', evict);
|
|
313
332
|
client.on('end', evict);
|
|
314
|
-
|
|
315
|
-
|
|
333
|
+
// Retry up to 3 times with 2s backoff. Covers the window between K8s PodReady
|
|
334
|
+
// and Valkey actually accepting connections (RDB/AOF load, ~1-3s on cold start).
|
|
335
|
+
let connectErr;
|
|
336
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
337
|
+
try {
|
|
338
|
+
if (attempt > 0) {
|
|
339
|
+
await new Promise(r => setTimeout(r, 2000 * attempt));
|
|
340
|
+
// Fresh client on retry — ioredis doesn't allow reconnect after explicit disconnect.
|
|
341
|
+
client.disconnect();
|
|
342
|
+
const retryClient = new Redis({
|
|
343
|
+
host: inst.host,
|
|
344
|
+
port: inst.port,
|
|
345
|
+
password: password || undefined,
|
|
346
|
+
...(tlsEnabled ? { tls: {} } : {}),
|
|
347
|
+
lazyConnect: true,
|
|
348
|
+
enableReadyCheck: true,
|
|
349
|
+
connectTimeout: 8000,
|
|
350
|
+
retryStrategy: () => null,
|
|
351
|
+
});
|
|
352
|
+
retryClient.on('error', () => pool.delete(instance_id));
|
|
353
|
+
retryClient.on('end', () => pool.delete(instance_id));
|
|
354
|
+
await retryClient.connect();
|
|
355
|
+
pool.set(instance_id, retryClient);
|
|
356
|
+
return retryClient;
|
|
357
|
+
}
|
|
358
|
+
await client.connect();
|
|
359
|
+
connectErr = undefined;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
connectErr = err;
|
|
364
|
+
}
|
|
316
365
|
}
|
|
317
|
-
|
|
366
|
+
if (connectErr) {
|
|
318
367
|
client.disconnect();
|
|
319
|
-
const msg =
|
|
368
|
+
const msg = connectErr instanceof Error ? connectErr.message : String(connectErr);
|
|
320
369
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
321
370
|
method: 'POST',
|
|
322
|
-
headers: { 'Content-Type': 'application/json', ...(
|
|
371
|
+
headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
|
|
323
372
|
body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
|
|
324
373
|
signal: AbortSignal.timeout(3000),
|
|
325
374
|
}).catch(() => { });
|
|
@@ -333,14 +382,14 @@ async function getConnection(instance_id) {
|
|
|
333
382
|
}
|
|
334
383
|
// ── API helper ────────────────────────────────────────────────────────────────
|
|
335
384
|
async function apiFetch(path, options = {}) {
|
|
336
|
-
checkJwt(
|
|
385
|
+
checkJwt(getJWT());
|
|
337
386
|
const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
|
|
338
387
|
const res = await fetch(`${API_URL}${path}`, {
|
|
339
388
|
...options,
|
|
340
389
|
signal: AbortSignal.timeout(timeoutMs),
|
|
341
390
|
headers: {
|
|
342
391
|
'Content-Type': 'application/json',
|
|
343
|
-
Authorization: `Bearer ${
|
|
392
|
+
Authorization: `Bearer ${getJWT()}`,
|
|
344
393
|
...options.headers,
|
|
345
394
|
},
|
|
346
395
|
});
|
|
@@ -384,7 +433,7 @@ function sendFunnelEvent(event, extra) {
|
|
|
384
433
|
headers: { 'Content-Type': 'application/json' },
|
|
385
434
|
body: JSON.stringify({
|
|
386
435
|
event, version: CURRENT_VERSION, editor: detectEditor(),
|
|
387
|
-
...(
|
|
436
|
+
...(getJWT() ? { jwt: getJWT() } : {}),
|
|
388
437
|
...extra,
|
|
389
438
|
}),
|
|
390
439
|
signal: AbortSignal.timeout(3000),
|
|
@@ -408,7 +457,7 @@ async function sendAnonymousTelemetry(toolName) {
|
|
|
408
457
|
}
|
|
409
458
|
async function handleTool(name, args) {
|
|
410
459
|
// Guard: if no JWT, return actionable onboarding message instead of HTTP 401
|
|
411
|
-
if (!
|
|
460
|
+
if (!getJWT()) {
|
|
412
461
|
void sendAnonymousTelemetry(name);
|
|
413
462
|
// ── Zero-credential device flow ─────────────────────────────────────
|
|
414
463
|
// 1st call: start device flow, return code + URL
|
|
@@ -485,14 +534,14 @@ async function handleTool(name, args) {
|
|
|
485
534
|
// Delegate brain tools (learn, recall, session, etc.)
|
|
486
535
|
const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
|
|
487
536
|
if (brainResult !== null) {
|
|
488
|
-
if (!_firstCallSuccessSent &&
|
|
537
|
+
if (!_firstCallSuccessSent && getJWT()) {
|
|
489
538
|
_firstCallSuccessSent = true;
|
|
490
|
-
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ??
|
|
539
|
+
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
|
|
491
540
|
}
|
|
492
541
|
// Per-tool telemetry — fires after every successful brain tool call.
|
|
493
542
|
// api_key carries the cky_live_... token so the backend can resolve tenant + increment counters.
|
|
494
|
-
const instanceId = args.instance_id ??
|
|
495
|
-
const telemetryExtra =
|
|
543
|
+
const instanceId = args.instance_id ?? getDefaultInstanceId() ?? '';
|
|
544
|
+
const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
|
|
496
545
|
if (name === 'recall_best_solution') {
|
|
497
546
|
// recall_best_solution → increments BrainRecallCount + triggers level-up logic
|
|
498
547
|
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
@@ -557,9 +606,9 @@ async function handleTool(name, args) {
|
|
|
557
606
|
// Delegate context tools (remember/recall/list/forget)
|
|
558
607
|
const contextResult = await handleContextTool(name, args, getConnection, apiFetch);
|
|
559
608
|
if (contextResult !== null) {
|
|
560
|
-
if (!_firstCallSuccessSent &&
|
|
609
|
+
if (!_firstCallSuccessSent && getJWT()) {
|
|
561
610
|
_firstCallSuccessSent = true;
|
|
562
|
-
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ??
|
|
611
|
+
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
|
|
563
612
|
}
|
|
564
613
|
return contextResult;
|
|
565
614
|
}
|
|
@@ -584,8 +633,8 @@ async function handleTool(name, args) {
|
|
|
584
633
|
if (syndicateResult !== null) {
|
|
585
634
|
// brain_predict is in syndicate handler — track telemetry here.
|
|
586
635
|
if (name === 'brain_predict') {
|
|
587
|
-
const instanceId = args.instance_id ??
|
|
588
|
-
const telemetryExtra =
|
|
636
|
+
const instanceId = args.instance_id ?? getDefaultInstanceId() ?? '';
|
|
637
|
+
const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
|
|
589
638
|
sendFunnelEvent('brain_predict', telemetryExtra);
|
|
590
639
|
}
|
|
591
640
|
return syndicateResult;
|
|
@@ -593,8 +642,8 @@ async function handleTool(name, args) {
|
|
|
593
642
|
const fedbrainResult = await handleFedbrainTool(name, args, getConnection, apiFetch);
|
|
594
643
|
if (fedbrainResult !== null) {
|
|
595
644
|
// brain_from_git and brain_predict_failures are in fedbrain handler.
|
|
596
|
-
const instanceId = args.instance_id ??
|
|
597
|
-
const telemetryExtra =
|
|
645
|
+
const instanceId = args.instance_id ?? getDefaultInstanceId() ?? '';
|
|
646
|
+
const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
|
|
598
647
|
if (name === 'brain_from_git') {
|
|
599
648
|
sendFunnelEvent('brain_from_git', telemetryExtra);
|
|
600
649
|
}
|
|
@@ -622,7 +671,7 @@ async function handleTool(name, args) {
|
|
|
622
671
|
healthStatus = `unreachable: ${e.message}`;
|
|
623
672
|
}
|
|
624
673
|
// Check JWT / auth
|
|
625
|
-
if (!
|
|
674
|
+
if (!getJWT()) {
|
|
626
675
|
return [
|
|
627
676
|
`📡 **cachly API Status**`,
|
|
628
677
|
``,
|
|
@@ -636,7 +685,7 @@ async function handleTool(name, args) {
|
|
|
636
685
|
// Decode JWT claims (inspection only, no verification)
|
|
637
686
|
let authInfo = '❌ invalid JWT format';
|
|
638
687
|
try {
|
|
639
|
-
const parts =
|
|
688
|
+
const parts = getJWT().split('.');
|
|
640
689
|
if (parts.length === 3) {
|
|
641
690
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
|
642
691
|
const sub = payload.sub ?? '(unknown)';
|
|
@@ -668,7 +717,7 @@ async function handleTool(name, args) {
|
|
|
668
717
|
const lines = ['\n\n🧠 **Brain Instances:**'];
|
|
669
718
|
for (const inst of instances) {
|
|
670
719
|
const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
|
|
671
|
-
const defaultMark = inst.id ===
|
|
720
|
+
const defaultMark = inst.id === getDefaultInstanceId() ? ' ← active' : '';
|
|
672
721
|
lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
|
|
673
722
|
if (inst.status === 'running' && inst.host) {
|
|
674
723
|
// Try a quick ping on the cached connection
|
|
@@ -2022,7 +2071,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2022
2071
|
const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
|
|
2023
2072
|
const CLIENT_ID = 'cachly-cli';
|
|
2024
2073
|
console.log('Step 1: Sign in to cachly (free, no credit card)\n');
|
|
2025
|
-
sendFunnelEvent('setup_auth_started');
|
|
2026
2074
|
// Start device flow
|
|
2027
2075
|
let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
|
|
2028
2076
|
try {
|
|
@@ -2048,7 +2096,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2048
2096
|
rl.close();
|
|
2049
2097
|
process.exit(1);
|
|
2050
2098
|
}
|
|
2051
|
-
sendFunnelEvent('setup_auth_completed');
|
|
2052
2099
|
console.log('');
|
|
2053
2100
|
deviceCode = ''; // mark as fallback so we skip polling
|
|
2054
2101
|
}
|
|
@@ -2081,7 +2128,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2081
2128
|
if (tokenData.access_token) {
|
|
2082
2129
|
token = tokenData.access_token;
|
|
2083
2130
|
console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
|
|
2084
|
-
sendFunnelEvent('setup_auth_completed');
|
|
2085
2131
|
break;
|
|
2086
2132
|
}
|
|
2087
2133
|
// authorization_pending = keep polling; slow_down = increase interval
|
|
@@ -2216,7 +2262,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2216
2262
|
console.log(` (Run with --instance-id <id> to use a different one)\n`);
|
|
2217
2263
|
}
|
|
2218
2264
|
console.log(`✓ Instance: ${instance.name} (${instance.id.slice(0, 8)}…)\n`);
|
|
2219
|
-
sendFunnelEvent('setup_instance_ready', { instance_id: instance.id });
|
|
2220
2265
|
// ── Step 3: Detect editors ────────────────────────────────────────────────
|
|
2221
2266
|
const cwd = process.cwd();
|
|
2222
2267
|
const detected = [];
|
|
@@ -2465,7 +2510,7 @@ if (process.argv[2] === 'index') {
|
|
|
2465
2510
|
const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
2466
2511
|
const maxFiles = parseInt(flag('max-files') ?? '500', 10);
|
|
2467
2512
|
const namespace = flag('namespace') ?? 'cachly:sem:code';
|
|
2468
|
-
if (!instanceId || !
|
|
2513
|
+
if (!instanceId || !getJWT()) {
|
|
2469
2514
|
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
2470
2515
|
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
2471
2516
|
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
@@ -2502,7 +2547,7 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2502
2547
|
const repoDir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
|
|
2503
2548
|
const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
2504
2549
|
const maxCommits = parseInt(flag('max-commits') ?? '50', 10);
|
|
2505
|
-
if (!instanceId || !
|
|
2550
|
+
if (!instanceId || !getJWT()) {
|
|
2506
2551
|
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
2507
2552
|
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
2508
2553
|
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
@@ -2534,7 +2579,8 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2534
2579
|
// actionable message in their editor's MCP log instead of silent failures.
|
|
2535
2580
|
// Skip for CLI commands that intentionally run without credentials.
|
|
2536
2581
|
const _cliNoAuthCommands = ['demo', 'share', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade'];
|
|
2537
|
-
|
|
2582
|
+
// In multi-tenant HTTP mode (PORT set) we never need a startup JWT — per-request auth.
|
|
2583
|
+
if (!JWT && !process.env.PORT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
|
|
2538
2584
|
// No args + no JWT in a non-TTY context = running as MCP server without credentials.
|
|
2539
2585
|
// Print actionable setup banner to stdout so it's captured by callers/tests.
|
|
2540
2586
|
const banner = '\n' +
|
|
@@ -2598,12 +2644,79 @@ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
|
|
|
2598
2644
|
})();
|
|
2599
2645
|
}
|
|
2600
2646
|
const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
2647
|
+
// ── Per-request MCP Server factory (HTTP / Smithery stateless mode) ──────────
|
|
2648
|
+
// SDK ≥1.9 forbids reusing a stateless transport across requests.
|
|
2649
|
+
// Solution: create a fresh Server+Transport per /mcp request.
|
|
2650
|
+
// The module-level `server` below is used for stdio mode only.
|
|
2651
|
+
function createHttpMcpServer() {
|
|
2652
|
+
const srv = new Server({ name: 'cachly-mcp', version: CURRENT_VERSION }, { capabilities: { tools: {} } });
|
|
2653
|
+
srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
2654
|
+
srv.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2655
|
+
const { name, arguments: args } = request.params;
|
|
2656
|
+
try {
|
|
2657
|
+
const text = await handleTool(name, (args ?? {}));
|
|
2658
|
+
return { content: [{ type: 'text', text }] };
|
|
2659
|
+
}
|
|
2660
|
+
catch (err) {
|
|
2661
|
+
if (err instanceof McpError)
|
|
2662
|
+
throw err;
|
|
2663
|
+
throw new McpError(ErrorCode.InternalError, err.message);
|
|
2664
|
+
}
|
|
2665
|
+
});
|
|
2666
|
+
return srv;
|
|
2667
|
+
}
|
|
2601
2668
|
if (httpPort) {
|
|
2602
2669
|
// ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
|
|
2603
2670
|
// Used for Smithery URL deployment: PORT=3000 node dist/index.js
|
|
2671
|
+
// Multi-tenant: per-request JWT + instance ID resolved via:
|
|
2672
|
+
// 1. Authorization: Bearer <jwt> + X-Cachly-Instance-Id: <uuid>
|
|
2673
|
+
// 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
|
|
2674
|
+
// Falls back to module-level env-derived values when neither is provided.
|
|
2604
2675
|
const { createServer } = await import('node:http');
|
|
2605
|
-
|
|
2606
|
-
|
|
2676
|
+
function extractReqCtx(req, url) {
|
|
2677
|
+
let jwtVal = '';
|
|
2678
|
+
let instId = '';
|
|
2679
|
+
const auth = req.headers['authorization'];
|
|
2680
|
+
if (typeof auth === 'string' && auth.trim()) {
|
|
2681
|
+
const a = auth.trim();
|
|
2682
|
+
jwtVal = a.toLowerCase().startsWith('bearer ') ? a.slice(7).trim() : a;
|
|
2683
|
+
}
|
|
2684
|
+
// Fallback: raw JWT in custom header (Smithery "header" transport without Bearer prefix)
|
|
2685
|
+
if (!jwtVal) {
|
|
2686
|
+
const rawJwt = req.headers['cachly_jwt'] ?? req.headers['cachly-jwt'] ?? req.headers['x-cachly-jwt'] ?? req.headers['x-api-key'];
|
|
2687
|
+
if (typeof rawJwt === 'string')
|
|
2688
|
+
jwtVal = rawJwt.trim();
|
|
2689
|
+
}
|
|
2690
|
+
const hdrInst = req.headers['x-cachly-instance-id'];
|
|
2691
|
+
if (typeof hdrInst === 'string')
|
|
2692
|
+
instId = hdrInst.trim();
|
|
2693
|
+
// Smithery passes user config as a base64url-encoded JSON blob in ?config=
|
|
2694
|
+
const cfgParam = url.searchParams.get('config');
|
|
2695
|
+
if (cfgParam && (!jwtVal || !instId)) {
|
|
2696
|
+
try {
|
|
2697
|
+
const b64 = cfgParam.replace(/-/g, '+').replace(/_/g, '/');
|
|
2698
|
+
const padded = b64 + '='.repeat((4 - b64.length % 4) % 4);
|
|
2699
|
+
const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
2700
|
+
if (!jwtVal && typeof json.CACHLY_JWT === 'string')
|
|
2701
|
+
jwtVal = json.CACHLY_JWT;
|
|
2702
|
+
if (!instId && typeof json.CACHLY_BRAIN_INSTANCE_ID === 'string')
|
|
2703
|
+
instId = json.CACHLY_BRAIN_INSTANCE_ID;
|
|
2704
|
+
}
|
|
2705
|
+
catch { /* malformed config — ignore, fall back to globals */ }
|
|
2706
|
+
}
|
|
2707
|
+
// Direct query params (Smithery "query" transport, manual clients, etc.)
|
|
2708
|
+
if (!jwtVal) {
|
|
2709
|
+
const q = url.searchParams.get('CACHLY_JWT') ?? url.searchParams.get('cachly_jwt') ?? url.searchParams.get('api_key');
|
|
2710
|
+
if (q)
|
|
2711
|
+
jwtVal = q.trim();
|
|
2712
|
+
}
|
|
2713
|
+
if (!instId) {
|
|
2714
|
+
const q = url.searchParams.get('CACHLY_BRAIN_INSTANCE_ID') ?? url.searchParams.get('cachly_brain_instance_id') ?? url.searchParams.get('instance_id');
|
|
2715
|
+
if (q)
|
|
2716
|
+
instId = q.trim();
|
|
2717
|
+
}
|
|
2718
|
+
return { jwt: jwtVal, instanceId: instId };
|
|
2719
|
+
}
|
|
2607
2720
|
const httpServer = createServer((req, res) => {
|
|
2608
2721
|
const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
|
|
2609
2722
|
if (url.pathname === '/health') {
|
|
@@ -2611,6 +2724,8 @@ if (httpPort) {
|
|
|
2611
2724
|
res.end(JSON.stringify({
|
|
2612
2725
|
status: 'ok',
|
|
2613
2726
|
version: CURRENT_VERSION,
|
|
2727
|
+
transport: 'streamable-http',
|
|
2728
|
+
multiTenant: true,
|
|
2614
2729
|
zeroResults: {
|
|
2615
2730
|
total: zeroResultsTotal,
|
|
2616
2731
|
last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
|
|
@@ -2618,8 +2733,52 @@ if (httpPort) {
|
|
|
2618
2733
|
}));
|
|
2619
2734
|
return;
|
|
2620
2735
|
}
|
|
2736
|
+
// Static server card — lets Smithery skip live scanning (avoids auth complexity).
|
|
2737
|
+
if (url.pathname === '/.well-known/mcp/server-card.json') {
|
|
2738
|
+
const card = {
|
|
2739
|
+
name: 'cachly AI Brain',
|
|
2740
|
+
version: CURRENT_VERSION,
|
|
2741
|
+
description: 'Persistent AI memory for Claude Code, Cursor, Copilot, Windsurf & friends. Remembers every lesson, fix, and architecture decision forever.',
|
|
2742
|
+
transport: { type: 'streamable-http', url: 'https://mcp.cachly.dev/mcp' },
|
|
2743
|
+
capabilities: { tools: {} },
|
|
2744
|
+
};
|
|
2745
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
2746
|
+
res.end(JSON.stringify(card));
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2621
2749
|
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
2622
|
-
|
|
2750
|
+
// Handle CORS preflight
|
|
2751
|
+
if (req.method === 'OPTIONS') {
|
|
2752
|
+
res.writeHead(204, {
|
|
2753
|
+
'Access-Control-Allow-Origin': '*',
|
|
2754
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
2755
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, X-Cachly-Instance-Id, cachly_jwt, cachly-jwt, x-cachly-jwt, x-api-key',
|
|
2756
|
+
});
|
|
2757
|
+
res.end();
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
const ctx = extractReqCtx(req, url);
|
|
2761
|
+
// Create a fresh Server+Transport for each request (stateless multi-tenant mode).
|
|
2762
|
+
// SDK ≥1.9 throws if a stateless transport is reused across requests.
|
|
2763
|
+
const reqServer = createHttpMcpServer();
|
|
2764
|
+
const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
2765
|
+
reqServer.connect(reqTransport).then(() => {
|
|
2766
|
+
reqCtx.run(ctx, () => {
|
|
2767
|
+
reqTransport.handleRequest(req, res).catch((err) => {
|
|
2768
|
+
process.stderr.write(`cachly-mcp HTTP handler error: ${err.message}\n`);
|
|
2769
|
+
if (!res.headersSent) {
|
|
2770
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2771
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
2772
|
+
}
|
|
2773
|
+
});
|
|
2774
|
+
});
|
|
2775
|
+
}).catch((err) => {
|
|
2776
|
+
process.stderr.write(`cachly-mcp connect error: ${err.message}\n`);
|
|
2777
|
+
if (!res.headersSent) {
|
|
2778
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2779
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
2780
|
+
}
|
|
2781
|
+
});
|
|
2623
2782
|
return;
|
|
2624
2783
|
}
|
|
2625
2784
|
res.writeHead(404);
|