@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/src/index.ts
CHANGED
|
@@ -54,13 +54,23 @@ import {
|
|
|
54
54
|
McpError,
|
|
55
55
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
56
56
|
import { Redis } from 'ioredis';
|
|
57
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
57
58
|
|
|
58
59
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
59
60
|
|
|
60
61
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
61
62
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
62
63
|
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
63
|
-
const CURRENT_VERSION = '0.10.
|
|
64
|
+
const CURRENT_VERSION = '0.10.47';
|
|
65
|
+
|
|
66
|
+
// ── Per-request context (multi-tenant HTTP / Smithery) ───────────────────────
|
|
67
|
+
// In stdio mode the module-level JWT/instance globals apply.
|
|
68
|
+
// In HTTP mode, the request handler runs each request in an ALS scope that
|
|
69
|
+
// shadows JWT + instance ID with per-user values extracted from headers.
|
|
70
|
+
interface ReqCtx { jwt: string; instanceId: string; }
|
|
71
|
+
const reqCtx = new AsyncLocalStorage<ReqCtx>();
|
|
72
|
+
function getJWT(): string { return reqCtx.getStore()?.jwt || JWT; }
|
|
73
|
+
function getDefaultInstanceId(): string { return reqCtx.getStore()?.instanceId || _defaultInstanceId; }
|
|
64
74
|
|
|
65
75
|
// ── Default Instance Resolution (for Smithery & single-credential setups) ────
|
|
66
76
|
// When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
|
|
@@ -70,15 +80,18 @@ let _defaultInstanceId: string = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
|
|
|
70
80
|
let _defaultInstanceLastAttempt = 0;
|
|
71
81
|
|
|
72
82
|
async function resolveDefaultInstanceId(): Promise<string> {
|
|
83
|
+
// In an HTTP request scope the instance ID is fixed by the caller's header.
|
|
84
|
+
const scopedId = reqCtx.getStore()?.instanceId;
|
|
85
|
+
if (scopedId) return scopedId;
|
|
73
86
|
if (_defaultInstanceId) return _defaultInstanceId;
|
|
74
|
-
if (!
|
|
87
|
+
if (!getJWT()) return '';
|
|
75
88
|
// Cooldown: don't hammer the API on every tool call after a transient failure.
|
|
76
89
|
const now = Date.now();
|
|
77
90
|
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000) return '';
|
|
78
91
|
_defaultInstanceLastAttempt = now;
|
|
79
92
|
try {
|
|
80
93
|
const res = await fetch(`${API_URL}/api/v1/instances`, {
|
|
81
|
-
headers: { Authorization: `Bearer ${
|
|
94
|
+
headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
|
|
82
95
|
signal: AbortSignal.timeout(5000),
|
|
83
96
|
});
|
|
84
97
|
if (!res.ok) return '';
|
|
@@ -174,7 +187,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
174
187
|
try {
|
|
175
188
|
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
176
189
|
method: 'POST',
|
|
177
|
-
headers: { Authorization: `Bearer ${
|
|
190
|
+
headers: { Authorization: `Bearer ${getJWT()}`, 'Content-Type': 'application/json' },
|
|
178
191
|
signal: AbortSignal.timeout(12000),
|
|
179
192
|
});
|
|
180
193
|
if (autoRes.ok) {
|
|
@@ -189,7 +202,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
189
202
|
// Log so telemetry can detect free-tier auto-provision failures
|
|
190
203
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
191
204
|
method: 'POST',
|
|
192
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${
|
|
205
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getJWT()}` },
|
|
193
206
|
body: JSON.stringify({ event: 'auto_provision_failed', status: autoRes.status, version: CURRENT_VERSION }),
|
|
194
207
|
signal: AbortSignal.timeout(3000),
|
|
195
208
|
}).catch(() => {});
|
|
@@ -201,7 +214,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
201
214
|
if (_defaultInstanceId) {
|
|
202
215
|
try {
|
|
203
216
|
const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
204
|
-
headers: { Authorization: `Bearer ${
|
|
217
|
+
headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
|
|
205
218
|
signal: AbortSignal.timeout(4000),
|
|
206
219
|
});
|
|
207
220
|
if (checkRes.ok) {
|
|
@@ -212,7 +225,7 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
212
225
|
while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
|
|
213
226
|
await new Promise(r => setTimeout(r, 3000));
|
|
214
227
|
const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
215
|
-
headers: { Authorization: `Bearer ${
|
|
228
|
+
headers: { Authorization: `Bearer ${getJWT()}`, Accept: 'application/json' },
|
|
216
229
|
signal: AbortSignal.timeout(4000),
|
|
217
230
|
}).catch(() => null);
|
|
218
231
|
if (rr?.ok) {
|
|
@@ -283,7 +296,19 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
283
296
|
);
|
|
284
297
|
}
|
|
285
298
|
|
|
286
|
-
if (pool.has(instance_id))
|
|
299
|
+
if (pool.has(instance_id)) {
|
|
300
|
+
const cached = pool.get(instance_id)!;
|
|
301
|
+
// Verify the pooled connection is still alive before reusing it.
|
|
302
|
+
// ioredis 'error' eviction is async — a connection can be in the pool
|
|
303
|
+
// but already internally broken (status 'reconnecting' or 'end').
|
|
304
|
+
const status = (cached as any).status as string | undefined;
|
|
305
|
+
if (status === 'ready' || status === 'connect') {
|
|
306
|
+
return cached;
|
|
307
|
+
}
|
|
308
|
+
// Stale — evict and fall through to reconnect.
|
|
309
|
+
pool.delete(instance_id);
|
|
310
|
+
cached.disconnect();
|
|
311
|
+
}
|
|
287
312
|
|
|
288
313
|
// Fetch instance, waiting up to 25 s if it is still provisioning.
|
|
289
314
|
// This covers the zero-friction path: device-flow auth → auto-provision → first tool call
|
|
@@ -301,7 +326,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
301
326
|
// Telemetry: unreachable instance so the team can proactively investigate
|
|
302
327
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
303
328
|
method: 'POST',
|
|
304
|
-
headers: { 'Content-Type': 'application/json', ...(
|
|
329
|
+
headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
|
|
305
330
|
body: JSON.stringify({ event: 'instance_not_reachable', instance_id, status: inst.status, version: CURRENT_VERSION }),
|
|
306
331
|
signal: AbortSignal.timeout(3000),
|
|
307
332
|
}).catch(() => {});
|
|
@@ -365,14 +390,45 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
365
390
|
client.on('error', evict);
|
|
366
391
|
client.on('end', evict);
|
|
367
392
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
393
|
+
// Retry up to 3 times with 2s backoff. Covers the window between K8s PodReady
|
|
394
|
+
// and Valkey actually accepting connections (RDB/AOF load, ~1-3s on cold start).
|
|
395
|
+
let connectErr: unknown;
|
|
396
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
397
|
+
try {
|
|
398
|
+
if (attempt > 0) {
|
|
399
|
+
await new Promise(r => setTimeout(r, 2000 * attempt));
|
|
400
|
+
// Fresh client on retry — ioredis doesn't allow reconnect after explicit disconnect.
|
|
401
|
+
client.disconnect();
|
|
402
|
+
const retryClient = new Redis({
|
|
403
|
+
host: inst.host,
|
|
404
|
+
port: inst.port,
|
|
405
|
+
password: password || undefined,
|
|
406
|
+
...(tlsEnabled ? { tls: {} } : {}),
|
|
407
|
+
lazyConnect: true,
|
|
408
|
+
enableReadyCheck: true,
|
|
409
|
+
connectTimeout: 8000,
|
|
410
|
+
retryStrategy: () => null,
|
|
411
|
+
});
|
|
412
|
+
retryClient.on('error', () => pool.delete(instance_id));
|
|
413
|
+
retryClient.on('end', () => pool.delete(instance_id));
|
|
414
|
+
await retryClient.connect();
|
|
415
|
+
pool.set(instance_id, retryClient);
|
|
416
|
+
return retryClient;
|
|
417
|
+
}
|
|
418
|
+
await client.connect();
|
|
419
|
+
connectErr = undefined;
|
|
420
|
+
break;
|
|
421
|
+
} catch (err) {
|
|
422
|
+
connectErr = err;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (connectErr) {
|
|
371
427
|
client.disconnect();
|
|
372
|
-
const msg =
|
|
428
|
+
const msg = connectErr instanceof Error ? connectErr.message : String(connectErr);
|
|
373
429
|
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
374
430
|
method: 'POST',
|
|
375
|
-
headers: { 'Content-Type': 'application/json', ...(
|
|
431
|
+
headers: { 'Content-Type': 'application/json', ...(getJWT() ? { Authorization: `Bearer ${getJWT()}` } : {}) },
|
|
376
432
|
body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
|
|
377
433
|
signal: AbortSignal.timeout(3000),
|
|
378
434
|
}).catch(() => {});
|
|
@@ -392,14 +448,14 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
392
448
|
// ── API helper ────────────────────────────────────────────────────────────────
|
|
393
449
|
|
|
394
450
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
395
|
-
checkJwt(
|
|
451
|
+
checkJwt(getJWT());
|
|
396
452
|
const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
|
|
397
453
|
const res = await fetch(`${API_URL}${path}`, {
|
|
398
454
|
...options,
|
|
399
455
|
signal: AbortSignal.timeout(timeoutMs),
|
|
400
456
|
headers: {
|
|
401
457
|
'Content-Type': 'application/json',
|
|
402
|
-
Authorization: `Bearer ${
|
|
458
|
+
Authorization: `Bearer ${getJWT()}`,
|
|
403
459
|
...options.headers,
|
|
404
460
|
},
|
|
405
461
|
});
|
|
@@ -454,7 +510,7 @@ function sendFunnelEvent(event: string, extra?: Record<string, unknown>): void {
|
|
|
454
510
|
headers: { 'Content-Type': 'application/json' },
|
|
455
511
|
body: JSON.stringify({
|
|
456
512
|
event, version: CURRENT_VERSION, editor: detectEditor(),
|
|
457
|
-
...(
|
|
513
|
+
...(getJWT() ? { jwt: getJWT() } : {}),
|
|
458
514
|
...extra,
|
|
459
515
|
}),
|
|
460
516
|
signal: AbortSignal.timeout(3000),
|
|
@@ -477,7 +533,7 @@ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
|
|
|
477
533
|
|
|
478
534
|
async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
479
535
|
// Guard: if no JWT, return actionable onboarding message instead of HTTP 401
|
|
480
|
-
if (!
|
|
536
|
+
if (!getJWT()) {
|
|
481
537
|
void sendAnonymousTelemetry(name);
|
|
482
538
|
|
|
483
539
|
// ── Zero-credential device flow ─────────────────────────────────────
|
|
@@ -557,14 +613,14 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
557
613
|
// Delegate brain tools (learn, recall, session, etc.)
|
|
558
614
|
const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
|
|
559
615
|
if (brainResult !== null) {
|
|
560
|
-
if (!_firstCallSuccessSent &&
|
|
616
|
+
if (!_firstCallSuccessSent && getJWT()) {
|
|
561
617
|
_firstCallSuccessSent = true;
|
|
562
|
-
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ??
|
|
618
|
+
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
|
|
563
619
|
}
|
|
564
620
|
// Per-tool telemetry — fires after every successful brain tool call.
|
|
565
621
|
// api_key carries the cky_live_... token so the backend can resolve tenant + increment counters.
|
|
566
|
-
const instanceId = (args.instance_id as string | undefined) ??
|
|
567
|
-
const telemetryExtra =
|
|
622
|
+
const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
|
|
623
|
+
const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
|
|
568
624
|
if (name === 'recall_best_solution') {
|
|
569
625
|
// recall_best_solution → increments BrainRecallCount + triggers level-up logic
|
|
570
626
|
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
@@ -625,9 +681,9 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
625
681
|
// Delegate context tools (remember/recall/list/forget)
|
|
626
682
|
const contextResult = await handleContextTool(name, args, getConnection, apiFetch);
|
|
627
683
|
if (contextResult !== null) {
|
|
628
|
-
if (!_firstCallSuccessSent &&
|
|
684
|
+
if (!_firstCallSuccessSent && getJWT()) {
|
|
629
685
|
_firstCallSuccessSent = true;
|
|
630
|
-
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ??
|
|
686
|
+
sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? getDefaultInstanceId() ?? '' });
|
|
631
687
|
}
|
|
632
688
|
return contextResult;
|
|
633
689
|
}
|
|
@@ -653,8 +709,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
653
709
|
if (syndicateResult !== null) {
|
|
654
710
|
// brain_predict is in syndicate handler — track telemetry here.
|
|
655
711
|
if (name === 'brain_predict') {
|
|
656
|
-
const instanceId = (args.instance_id as string | undefined) ??
|
|
657
|
-
const telemetryExtra =
|
|
712
|
+
const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
|
|
713
|
+
const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
|
|
658
714
|
sendFunnelEvent('brain_predict', telemetryExtra);
|
|
659
715
|
}
|
|
660
716
|
return syndicateResult;
|
|
@@ -663,8 +719,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
663
719
|
const fedbrainResult = await handleFedbrainTool(name, args, getConnection, apiFetch);
|
|
664
720
|
if (fedbrainResult !== null) {
|
|
665
721
|
// brain_from_git and brain_predict_failures are in fedbrain handler.
|
|
666
|
-
const instanceId = (args.instance_id as string | undefined) ??
|
|
667
|
-
const telemetryExtra =
|
|
722
|
+
const instanceId = (args.instance_id as string | undefined) ?? getDefaultInstanceId() ?? '';
|
|
723
|
+
const telemetryExtra = getJWT() ? { api_key: getJWT(), instance_id: instanceId } : { instance_id: instanceId };
|
|
668
724
|
if (name === 'brain_from_git') {
|
|
669
725
|
sendFunnelEvent('brain_from_git', telemetryExtra);
|
|
670
726
|
} else if (name === 'brain_predict_failures') {
|
|
@@ -691,7 +747,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
691
747
|
}
|
|
692
748
|
|
|
693
749
|
// Check JWT / auth
|
|
694
|
-
if (!
|
|
750
|
+
if (!getJWT()) {
|
|
695
751
|
return [
|
|
696
752
|
`📡 **cachly API Status**`,
|
|
697
753
|
``,
|
|
@@ -706,7 +762,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
706
762
|
// Decode JWT claims (inspection only, no verification)
|
|
707
763
|
let authInfo = '❌ invalid JWT format';
|
|
708
764
|
try {
|
|
709
|
-
const parts =
|
|
765
|
+
const parts = getJWT().split('.');
|
|
710
766
|
if (parts.length === 3) {
|
|
711
767
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
|
|
712
768
|
sub?: string; exp?: number; iss?: string;
|
|
@@ -739,7 +795,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
739
795
|
const lines: string[] = ['\n\n🧠 **Brain Instances:**'];
|
|
740
796
|
for (const inst of instances) {
|
|
741
797
|
const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
|
|
742
|
-
const defaultMark = inst.id ===
|
|
798
|
+
const defaultMark = inst.id === getDefaultInstanceId() ? ' ← active' : '';
|
|
743
799
|
lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
|
|
744
800
|
if (inst.status === 'running' && inst.host) {
|
|
745
801
|
// Try a quick ping on the cached connection
|
|
@@ -2156,7 +2212,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2156
2212
|
const CLIENT_ID = 'cachly-cli';
|
|
2157
2213
|
|
|
2158
2214
|
console.log('Step 1: Sign in to cachly (free, no credit card)\n');
|
|
2159
|
-
sendFunnelEvent('setup_auth_started');
|
|
2160
2215
|
|
|
2161
2216
|
// Start device flow
|
|
2162
2217
|
let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
|
|
@@ -2180,7 +2235,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2180
2235
|
console.error('Falling back: sign in at https://cachly.dev/setup-ai and paste your API token.\n');
|
|
2181
2236
|
token = await ask(' Paste API token (cky_live_...): ');
|
|
2182
2237
|
if (!token) { console.error('\nToken is required. Aborting.\n'); rl.close(); process.exit(1); }
|
|
2183
|
-
sendFunnelEvent('setup_auth_completed');
|
|
2184
2238
|
console.log('');
|
|
2185
2239
|
deviceCode = ''; // mark as fallback so we skip polling
|
|
2186
2240
|
}
|
|
@@ -2214,7 +2268,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2214
2268
|
if (tokenData.access_token) {
|
|
2215
2269
|
token = tokenData.access_token;
|
|
2216
2270
|
console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
|
|
2217
|
-
sendFunnelEvent('setup_auth_completed');
|
|
2218
2271
|
break;
|
|
2219
2272
|
}
|
|
2220
2273
|
// authorization_pending = keep polling; slow_down = increase interval
|
|
@@ -2331,7 +2384,6 @@ if (process.argv[2] === 'setup') {
|
|
|
2331
2384
|
console.log(` (Run with --instance-id <id> to use a different one)\n`);
|
|
2332
2385
|
}
|
|
2333
2386
|
console.log(`✓ Instance: ${instance.name} (${instance.id.slice(0, 8)}…)\n`);
|
|
2334
|
-
sendFunnelEvent('setup_instance_ready', { instance_id: instance.id });
|
|
2335
2387
|
|
|
2336
2388
|
// ── Step 3: Detect editors ────────────────────────────────────────────────
|
|
2337
2389
|
const cwd = process.cwd();
|
|
@@ -2577,7 +2629,7 @@ if (process.argv[2] === 'index') {
|
|
|
2577
2629
|
const maxFiles = parseInt(flag('max-files') ?? '500', 10);
|
|
2578
2630
|
const namespace = flag('namespace') ?? 'cachly:sem:code';
|
|
2579
2631
|
|
|
2580
|
-
if (!instanceId || !
|
|
2632
|
+
if (!instanceId || !getJWT()) {
|
|
2581
2633
|
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
2582
2634
|
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
2583
2635
|
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
@@ -2619,7 +2671,7 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2619
2671
|
const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
2620
2672
|
const maxCommits = parseInt(flag('max-commits') ?? '50', 10);
|
|
2621
2673
|
|
|
2622
|
-
if (!instanceId || !
|
|
2674
|
+
if (!instanceId || !getJWT()) {
|
|
2623
2675
|
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
2624
2676
|
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
2625
2677
|
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
@@ -2654,7 +2706,8 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2654
2706
|
// actionable message in their editor's MCP log instead of silent failures.
|
|
2655
2707
|
// Skip for CLI commands that intentionally run without credentials.
|
|
2656
2708
|
const _cliNoAuthCommands = ['demo', 'share', 'health', 'setup', 'init', 'digest', 'invite', 'badge', 'join', 'upgrade'];
|
|
2657
|
-
|
|
2709
|
+
// In multi-tenant HTTP mode (PORT set) we never need a startup JWT — per-request auth.
|
|
2710
|
+
if (!JWT && !process.env.PORT && !_cliNoAuthCommands.includes(process.argv[2] ?? '') && !(!process.argv[2] && process.stdout.isTTY)) {
|
|
2658
2711
|
// No args + no JWT in a non-TTY context = running as MCP server without credentials.
|
|
2659
2712
|
// Print actionable setup banner to stdout so it's captured by callers/tests.
|
|
2660
2713
|
const banner =
|
|
@@ -2727,12 +2780,76 @@ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
|
|
|
2727
2780
|
|
|
2728
2781
|
const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
2729
2782
|
|
|
2783
|
+
// ── Per-request MCP Server factory (HTTP / Smithery stateless mode) ──────────
|
|
2784
|
+
// SDK ≥1.9 forbids reusing a stateless transport across requests.
|
|
2785
|
+
// Solution: create a fresh Server+Transport per /mcp request.
|
|
2786
|
+
// The module-level `server` below is used for stdio mode only.
|
|
2787
|
+
function createHttpMcpServer(): Server {
|
|
2788
|
+
const srv = new Server(
|
|
2789
|
+
{ name: 'cachly-mcp', version: CURRENT_VERSION },
|
|
2790
|
+
{ capabilities: { tools: {} } }
|
|
2791
|
+
);
|
|
2792
|
+
srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
2793
|
+
srv.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2794
|
+
const { name, arguments: args } = request.params;
|
|
2795
|
+
try {
|
|
2796
|
+
const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
|
|
2797
|
+
return { content: [{ type: 'text', text }] };
|
|
2798
|
+
} catch (err) {
|
|
2799
|
+
if (err instanceof McpError) throw err;
|
|
2800
|
+
throw new McpError(ErrorCode.InternalError, (err as Error).message);
|
|
2801
|
+
}
|
|
2802
|
+
});
|
|
2803
|
+
return srv;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2730
2806
|
if (httpPort) {
|
|
2731
2807
|
// ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
|
|
2732
2808
|
// Used for Smithery URL deployment: PORT=3000 node dist/index.js
|
|
2809
|
+
// Multi-tenant: per-request JWT + instance ID resolved via:
|
|
2810
|
+
// 1. Authorization: Bearer <jwt> + X-Cachly-Instance-Id: <uuid>
|
|
2811
|
+
// 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
|
|
2812
|
+
// Falls back to module-level env-derived values when neither is provided.
|
|
2733
2813
|
const { createServer } = await import('node:http');
|
|
2734
|
-
|
|
2735
|
-
|
|
2814
|
+
|
|
2815
|
+
function extractReqCtx(req: import('node:http').IncomingMessage, url: URL): ReqCtx {
|
|
2816
|
+
let jwtVal = '';
|
|
2817
|
+
let instId = '';
|
|
2818
|
+
const auth = req.headers['authorization'];
|
|
2819
|
+
if (typeof auth === 'string' && auth.trim()) {
|
|
2820
|
+
const a = auth.trim();
|
|
2821
|
+
jwtVal = a.toLowerCase().startsWith('bearer ') ? a.slice(7).trim() : a;
|
|
2822
|
+
}
|
|
2823
|
+
// Fallback: raw JWT in custom header (Smithery "header" transport without Bearer prefix)
|
|
2824
|
+
if (!jwtVal) {
|
|
2825
|
+
const rawJwt = req.headers['cachly_jwt'] ?? req.headers['cachly-jwt'] ?? req.headers['x-cachly-jwt'] ?? req.headers['x-api-key'];
|
|
2826
|
+
if (typeof rawJwt === 'string') jwtVal = rawJwt.trim();
|
|
2827
|
+
}
|
|
2828
|
+
const hdrInst = req.headers['x-cachly-instance-id'];
|
|
2829
|
+
if (typeof hdrInst === 'string') instId = hdrInst.trim();
|
|
2830
|
+
// Smithery passes user config as a base64url-encoded JSON blob in ?config=
|
|
2831
|
+
const cfgParam = url.searchParams.get('config');
|
|
2832
|
+
if (cfgParam && (!jwtVal || !instId)) {
|
|
2833
|
+
try {
|
|
2834
|
+
const b64 = cfgParam.replace(/-/g, '+').replace(/_/g, '/');
|
|
2835
|
+
const padded = b64 + '='.repeat((4 - b64.length % 4) % 4);
|
|
2836
|
+
const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
2837
|
+
if (!jwtVal && typeof json.CACHLY_JWT === 'string') jwtVal = json.CACHLY_JWT;
|
|
2838
|
+
if (!instId && typeof json.CACHLY_BRAIN_INSTANCE_ID === 'string') instId = json.CACHLY_BRAIN_INSTANCE_ID;
|
|
2839
|
+
} catch { /* malformed config — ignore, fall back to globals */ }
|
|
2840
|
+
}
|
|
2841
|
+
// Direct query params (Smithery "query" transport, manual clients, etc.)
|
|
2842
|
+
if (!jwtVal) {
|
|
2843
|
+
const q = url.searchParams.get('CACHLY_JWT') ?? url.searchParams.get('cachly_jwt') ?? url.searchParams.get('api_key');
|
|
2844
|
+
if (q) jwtVal = q.trim();
|
|
2845
|
+
}
|
|
2846
|
+
if (!instId) {
|
|
2847
|
+
const q = url.searchParams.get('CACHLY_BRAIN_INSTANCE_ID') ?? url.searchParams.get('cachly_brain_instance_id') ?? url.searchParams.get('instance_id');
|
|
2848
|
+
if (q) instId = q.trim();
|
|
2849
|
+
}
|
|
2850
|
+
return { jwt: jwtVal, instanceId: instId };
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2736
2853
|
const httpServer = createServer((req, res) => {
|
|
2737
2854
|
const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
|
|
2738
2855
|
if (url.pathname === '/health') {
|
|
@@ -2740,6 +2857,8 @@ if (httpPort) {
|
|
|
2740
2857
|
res.end(JSON.stringify({
|
|
2741
2858
|
status: 'ok',
|
|
2742
2859
|
version: CURRENT_VERSION,
|
|
2860
|
+
transport: 'streamable-http',
|
|
2861
|
+
multiTenant: true,
|
|
2743
2862
|
zeroResults: {
|
|
2744
2863
|
total: zeroResultsTotal,
|
|
2745
2864
|
last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
|
|
@@ -2747,8 +2866,52 @@ if (httpPort) {
|
|
|
2747
2866
|
}));
|
|
2748
2867
|
return;
|
|
2749
2868
|
}
|
|
2869
|
+
// Static server card — lets Smithery skip live scanning (avoids auth complexity).
|
|
2870
|
+
if (url.pathname === '/.well-known/mcp/server-card.json') {
|
|
2871
|
+
const card = {
|
|
2872
|
+
name: 'cachly AI Brain',
|
|
2873
|
+
version: CURRENT_VERSION,
|
|
2874
|
+
description: 'Persistent AI memory for Claude Code, Cursor, Copilot, Windsurf & friends. Remembers every lesson, fix, and architecture decision forever.',
|
|
2875
|
+
transport: { type: 'streamable-http', url: 'https://mcp.cachly.dev/mcp' },
|
|
2876
|
+
capabilities: { tools: {} },
|
|
2877
|
+
};
|
|
2878
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
2879
|
+
res.end(JSON.stringify(card));
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2750
2882
|
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
2751
|
-
|
|
2883
|
+
// Handle CORS preflight
|
|
2884
|
+
if (req.method === 'OPTIONS') {
|
|
2885
|
+
res.writeHead(204, {
|
|
2886
|
+
'Access-Control-Allow-Origin': '*',
|
|
2887
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
2888
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, X-Cachly-Instance-Id, cachly_jwt, cachly-jwt, x-cachly-jwt, x-api-key',
|
|
2889
|
+
});
|
|
2890
|
+
res.end();
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
const ctx = extractReqCtx(req, url);
|
|
2894
|
+
// Create a fresh Server+Transport for each request (stateless multi-tenant mode).
|
|
2895
|
+
// SDK ≥1.9 throws if a stateless transport is reused across requests.
|
|
2896
|
+
const reqServer = createHttpMcpServer();
|
|
2897
|
+
const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
2898
|
+
reqServer.connect(reqTransport).then(() => {
|
|
2899
|
+
reqCtx.run(ctx, () => {
|
|
2900
|
+
reqTransport.handleRequest(req, res).catch((err) => {
|
|
2901
|
+
process.stderr.write(`cachly-mcp HTTP handler error: ${(err as Error).message}\n`);
|
|
2902
|
+
if (!res.headersSent) {
|
|
2903
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2904
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
2905
|
+
}
|
|
2906
|
+
});
|
|
2907
|
+
});
|
|
2908
|
+
}).catch((err) => {
|
|
2909
|
+
process.stderr.write(`cachly-mcp connect error: ${(err as Error).message}\n`);
|
|
2910
|
+
if (!res.headersSent) {
|
|
2911
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2912
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
2913
|
+
}
|
|
2914
|
+
});
|
|
2752
2915
|
return;
|
|
2753
2916
|
}
|
|
2754
2917
|
res.writeHead(404);
|
package/src/search.ts
CHANGED
|
@@ -384,7 +384,7 @@ function hindiLightStem(word: string): string {
|
|
|
384
384
|
* настройки → настройк (genitive plural)
|
|
385
385
|
*/
|
|
386
386
|
function russianLightStem(word: string): string {
|
|
387
|
-
|
|
387
|
+
const w = word;
|
|
388
388
|
// Longest first to avoid partial stripping
|
|
389
389
|
// Verb infinitives / gerunds
|
|
390
390
|
if (w.endsWith('ывание') && w.length > 7) return w.slice(0, -6);
|
|
@@ -427,7 +427,7 @@ function russianLightStem(word: string): string {
|
|
|
427
427
|
* yüklemek → yükle (-mek/-mak infinitive)
|
|
428
428
|
*/
|
|
429
429
|
function turkishLightStem(word: string): string {
|
|
430
|
-
|
|
430
|
+
const w = word;
|
|
431
431
|
// Suffixes longest-first
|
|
432
432
|
if (w.endsWith('lardaki') && w.length > 7) return w.slice(0, -7);
|
|
433
433
|
if (w.endsWith('lerdeki') && w.length > 7) return w.slice(0, -7);
|
|
@@ -475,7 +475,7 @@ function turkishLightStem(word: string): string {
|
|
|
475
475
|
* সমস্যাটি → সমস্যা (-টি singular marker)
|
|
476
476
|
*/
|
|
477
477
|
function bengaliLightStem(word: string): string {
|
|
478
|
-
|
|
478
|
+
const w = word;
|
|
479
479
|
// Plural / collective markers (longest first)
|
|
480
480
|
if (w.endsWith('\u0997\u09c1\u09b2\u09cb') && w.length > 5) return w.slice(0, -4); // গুলো (-gulo plural)
|
|
481
481
|
if (w.endsWith('\u0997\u09c1\u09b2\u09bf') && w.length > 5) return w.slice(0, -4); // গুলি (-guli plural)
|