@cachly-dev/mcp-server 0.10.47 → 0.10.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +2 -2
- package/README.md +33 -5
- package/dist/__tests__/brain-flow.test.js +2 -2
- package/dist/__tests__/brain-flow.test.js.map +1 -1
- package/dist/__tests__/handlers-unit.test.js +3 -1
- package/dist/__tests__/handlers-unit.test.js.map +1 -1
- package/dist/handlers/brain.js +90 -5
- 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 +4 -2
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +342 -83
- 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 +0 -2
- package/src/__tests__/brain-flow.test.ts +2 -2
- package/src/__tests__/handlers-unit.test.ts +2 -1
- package/src/handlers/brain.ts +90 -8
- package/src/handlers/fedbrain.ts +6 -5
- package/src/handlers/roadmap.ts +1 -1
- package/src/handlers/team.ts +4 -2
- package/src/index.ts +279 -107
- package/src/search.ts +3 -3
package/src/index.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import { execSync } from 'node:child_process';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
4
|
import { fileURLToPath } from 'node:url';
|
|
7
5
|
|
|
8
6
|
// True only when this file is the entry point (not imported by tests or other modules).
|
|
@@ -60,8 +58,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
60
58
|
|
|
61
59
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
62
60
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
63
|
-
const
|
|
64
|
-
const CURRENT_VERSION = '0.10.47';
|
|
61
|
+
const CURRENT_VERSION = '0.10.55';
|
|
65
62
|
|
|
66
63
|
// ── Per-request context (multi-tenant HTTP / Smithery) ───────────────────────
|
|
67
64
|
// In stdio mode the module-level JWT/instance globals apply.
|
|
@@ -86,8 +83,18 @@ async function resolveDefaultInstanceId(): Promise<string> {
|
|
|
86
83
|
if (_defaultInstanceId) return _defaultInstanceId;
|
|
87
84
|
if (!getJWT()) return '';
|
|
88
85
|
// Cooldown: don't hammer the API on every tool call after a transient failure.
|
|
86
|
+
// 10 s is enough to avoid rate-limit spam while not blocking the user for too long.
|
|
89
87
|
const now = Date.now();
|
|
90
|
-
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt <
|
|
88
|
+
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 10_000) {
|
|
89
|
+
throw new McpError(
|
|
90
|
+
ErrorCode.InvalidRequest,
|
|
91
|
+
'⚠️ Cachly Brain is temporarily unreachable (API error a few seconds ago).\n\n' +
|
|
92
|
+
'The server will retry automatically in a moment. If this persists:\n' +
|
|
93
|
+
'• Run `get_api_status` for a full diagnostic\n' +
|
|
94
|
+
'• Check that CACHLY_JWT is set and valid\n' +
|
|
95
|
+
'• Visit https://cachly.dev/instances to verify your Brain is running'
|
|
96
|
+
);
|
|
97
|
+
};
|
|
91
98
|
_defaultInstanceLastAttempt = now;
|
|
92
99
|
try {
|
|
93
100
|
const res = await fetch(`${API_URL}/api/v1/instances`, {
|
|
@@ -178,6 +185,26 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
178
185
|
} catch { /* use raw JWT as fallback */ }
|
|
179
186
|
JWT = apiKey;
|
|
180
187
|
setEmbedJwt(apiKey); // keep embeddings.ts in sync
|
|
188
|
+
// Persist API key to ~/.claude/mcp.json so the next editor restart
|
|
189
|
+
// doesn't start over from a missing CACHLY_JWT (fixes BrainRecallCount=0 bug).
|
|
190
|
+
try {
|
|
191
|
+
const { resolve: _res, dirname: _dir } = await import('node:path');
|
|
192
|
+
const { homedir: _hd } = await import('node:os');
|
|
193
|
+
const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
|
|
194
|
+
const { existsSync: _ex } = await import('node:fs');
|
|
195
|
+
const globalJwtPath = _res(_hd(), '.claude', 'mcp.json');
|
|
196
|
+
await _mk(_dir(globalJwtPath), { recursive: true });
|
|
197
|
+
let cfg: { mcpServers?: Record<string, { env?: Record<string, string> }> } = {};
|
|
198
|
+
if (_ex(globalJwtPath)) {
|
|
199
|
+
try { cfg = JSON.parse(await _rf(globalJwtPath, 'utf-8')) as typeof cfg; } catch { /* corrupt — start fresh */ }
|
|
200
|
+
}
|
|
201
|
+
cfg.mcpServers ??= {};
|
|
202
|
+
cfg.mcpServers['cachly'] ??= {};
|
|
203
|
+
cfg.mcpServers['cachly'].env ??= {};
|
|
204
|
+
cfg.mcpServers['cachly'].env['CACHLY_JWT'] = apiKey;
|
|
205
|
+
if (_defaultInstanceId) cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
|
|
206
|
+
await _wf(globalJwtPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
207
|
+
} catch { /* non-fatal — editor will just prompt again on next restart */ }
|
|
181
208
|
_deviceFlow = null;
|
|
182
209
|
sendFunnelEvent('device_flow_completed');
|
|
183
210
|
// Auto-provision: find or create the user's brain instance.
|
|
@@ -243,22 +270,61 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
243
270
|
return 'pending';
|
|
244
271
|
} catch { return 'pending'; }
|
|
245
272
|
}
|
|
246
|
-
// ──
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
273
|
+
// ── Silent Auto-Setup (instant-trial, zero-click) ─────────────────────────
|
|
274
|
+
// Attempts to provision a free trial silently on the first tool call when no
|
|
275
|
+
// JWT is set. Returns true if credentials were written and the tool can
|
|
276
|
+
// proceed transparently; false if the API is unreachable or trials are full.
|
|
277
|
+
// Only runs once per process (subsequent failures short-circuit immediately).
|
|
278
|
+
let _silentSetupAttempted = false;
|
|
279
|
+
async function silentAutoSetup(): Promise<boolean> {
|
|
280
|
+
if (_silentSetupAttempted) return false;
|
|
281
|
+
_silentSetupAttempted = true;
|
|
282
|
+
try {
|
|
283
|
+
type TrialResp = { api_key: string; expires_at?: string; instance_id?: string; trial?: boolean };
|
|
284
|
+
const res = await fetch(`${API_URL}/auth/instant-trial`, {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'Content-Type': 'application/json' },
|
|
287
|
+
body: JSON.stringify({}),
|
|
288
|
+
signal: AbortSignal.timeout(8000),
|
|
289
|
+
});
|
|
290
|
+
if (!res.ok) return false;
|
|
291
|
+
const data = await res.json() as TrialResp;
|
|
292
|
+
if (!data.api_key) return false;
|
|
251
293
|
|
|
294
|
+
JWT = data.api_key;
|
|
295
|
+
setEmbedJwt(data.api_key);
|
|
296
|
+
if (data.instance_id) _defaultInstanceId = data.instance_id;
|
|
297
|
+
sendFunnelEvent('trial_provisioned', { version: CURRENT_VERSION });
|
|
252
298
|
|
|
299
|
+
// Persist credentials so the next editor restart doesn't re-provision.
|
|
300
|
+
try {
|
|
301
|
+
const { resolve: _rp, dirname: _dn } = await import('node:path');
|
|
302
|
+
const { homedir: _hd } = await import('node:os');
|
|
303
|
+
const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
|
|
304
|
+
const { existsSync: _ex } = await import('node:fs');
|
|
305
|
+
const cfgPath = _rp(_hd(), '.claude', 'mcp.json');
|
|
306
|
+
await _mk(_dn(cfgPath), { recursive: true });
|
|
307
|
+
let cfg: { mcpServers?: Record<string, { env?: Record<string, string> }> } = {};
|
|
308
|
+
if (_ex(cfgPath)) {
|
|
309
|
+
try { cfg = JSON.parse(await _rf(cfgPath, 'utf-8')) as typeof cfg; } catch { /* corrupt — overwrite */ }
|
|
310
|
+
}
|
|
311
|
+
cfg.mcpServers ??= {};
|
|
312
|
+
cfg.mcpServers['cachly'] ??= {};
|
|
313
|
+
cfg.mcpServers['cachly'].env ??= {};
|
|
314
|
+
cfg.mcpServers['cachly'].env['CACHLY_JWT'] = data.api_key;
|
|
315
|
+
if (_defaultInstanceId) cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
|
|
316
|
+
await _wf(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
317
|
+
} catch { /* non-fatal — trial is active for this session even if persist fails */ }
|
|
318
|
+
return true;
|
|
319
|
+
} catch { return false; }
|
|
320
|
+
}
|
|
253
321
|
// ── Embeddings (imported from embeddings.ts) ────────────────────────────────
|
|
254
|
-
import {
|
|
255
|
-
hasEmbedProvider, embedProviderHint } from './embeddings.js';
|
|
322
|
+
import { setEmbedJwt } from './embeddings.js';
|
|
256
323
|
|
|
257
324
|
// ── Search Engine (imported from search.ts) ─────────────────────────────────
|
|
258
325
|
import { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
|
|
259
326
|
katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP,
|
|
260
|
-
|
|
261
|
-
import type { KeywordMatch } from './search.js';
|
|
327
|
+
ZERO_RESULTS_LOG, zeroResultsTotal } from './search.js';
|
|
262
328
|
|
|
263
329
|
// ── Exported for testing (re-export from search.ts) ─────────────────────────
|
|
264
330
|
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
|
|
@@ -267,19 +333,6 @@ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp,
|
|
|
267
333
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
268
334
|
// Instance is imported from handlers/brain.ts
|
|
269
335
|
|
|
270
|
-
interface CreateResponse {
|
|
271
|
-
instance_id: string;
|
|
272
|
-
checkout_url?: string;
|
|
273
|
-
status: string;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
interface SemanticSearchResponse {
|
|
277
|
-
found: boolean;
|
|
278
|
-
id?: string;
|
|
279
|
-
similarity?: number;
|
|
280
|
-
prompt?: string;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
336
|
// ── Connection pool ───────────────────────────────────────────────────────────
|
|
284
337
|
|
|
285
338
|
/** Reuse Redis connections across tool calls (keyed by instance_id). */
|
|
@@ -301,7 +354,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
301
354
|
// Verify the pooled connection is still alive before reusing it.
|
|
302
355
|
// ioredis 'error' eviction is async — a connection can be in the pool
|
|
303
356
|
// but already internally broken (status 'reconnecting' or 'end').
|
|
304
|
-
const status = (cached as
|
|
357
|
+
const status = (cached as { status?: string }).status;
|
|
305
358
|
if (status === 'ready' || status === 'connect') {
|
|
306
359
|
return cached;
|
|
307
360
|
}
|
|
@@ -310,14 +363,13 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
310
363
|
cached.disconnect();
|
|
311
364
|
}
|
|
312
365
|
|
|
313
|
-
// Fetch instance, waiting up to
|
|
314
|
-
//
|
|
315
|
-
// all happen in quick succession and the instance isn't running yet.
|
|
366
|
+
// Fetch instance, waiting up to 8 s if it is still provisioning.
|
|
367
|
+
// (autoStartSession already gave the instance a head-start; 8 s covers any remaining gap.)
|
|
316
368
|
let inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
317
369
|
if (inst.status === 'provisioning') {
|
|
318
|
-
const deadline = Date.now() +
|
|
370
|
+
const deadline = Date.now() + 8_000;
|
|
319
371
|
while (inst.status === 'provisioning' && Date.now() < deadline) {
|
|
320
|
-
await new Promise(r => setTimeout(r,
|
|
372
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
321
373
|
inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
322
374
|
}
|
|
323
375
|
}
|
|
@@ -357,7 +409,9 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
357
409
|
|
|
358
410
|
// Fetch connection details (includes password) from the dedicated endpoint.
|
|
359
411
|
let password: string | undefined;
|
|
360
|
-
|
|
412
|
+
// Default to false — most free/dev instances run without TLS.
|
|
413
|
+
// Only enable TLS when the API explicitly returns tls_enabled: true.
|
|
414
|
+
let tlsEnabled = inst.tls_enabled === true;
|
|
361
415
|
try {
|
|
362
416
|
const conn = await apiFetch<{ password?: string; tls_enabled?: boolean }>(
|
|
363
417
|
`/api/v1/instances/${instance_id}/connection`
|
|
@@ -383,6 +437,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
383
437
|
lazyConnect: true,
|
|
384
438
|
enableReadyCheck: true,
|
|
385
439
|
connectTimeout: 8000,
|
|
440
|
+
commandTimeout: 10000,
|
|
386
441
|
retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
|
|
387
442
|
});
|
|
388
443
|
|
|
@@ -407,6 +462,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
407
462
|
lazyConnect: true,
|
|
408
463
|
enableReadyCheck: true,
|
|
409
464
|
connectTimeout: 8000,
|
|
465
|
+
commandTimeout: 10000,
|
|
410
466
|
retryStrategy: () => null,
|
|
411
467
|
});
|
|
412
468
|
retryClient.on('error', () => pool.delete(instance_id));
|
|
@@ -468,11 +524,6 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|
|
468
524
|
return res.json() as Promise<T>;
|
|
469
525
|
}
|
|
470
526
|
|
|
471
|
-
import { detectNamespace } from './namespace.js';
|
|
472
|
-
|
|
473
|
-
// ── CKG (imported from ckg.ts) ─────────────────────────────────────────────
|
|
474
|
-
import type { CKGEdge, CKGNode } from './ckg.js';
|
|
475
|
-
import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from './ckg.js';
|
|
476
527
|
import { handleBrainTool } from './handlers/brain.js';
|
|
477
528
|
import { handleContextTool } from './handlers/context.js';
|
|
478
529
|
import { handleInstanceTool } from './handlers/instances.js';
|
|
@@ -510,7 +561,10 @@ function sendFunnelEvent(event: string, extra?: Record<string, unknown>): void {
|
|
|
510
561
|
headers: { 'Content-Type': 'application/json' },
|
|
511
562
|
body: JSON.stringify({
|
|
512
563
|
event, version: CURRENT_VERSION, editor: detectEditor(),
|
|
513
|
-
|
|
564
|
+
// Send Cachly API keys (cky_*) for server-side tenant resolution (recall counting, emails).
|
|
565
|
+
// Raw Keycloak JWTs (eyJ...) are intentionally excluded — they are short-lived bearer tokens
|
|
566
|
+
// that should never leave the MCP process boundary.
|
|
567
|
+
...(getJWT().startsWith('cky_') ? { api_key: getJWT() } : {}),
|
|
514
568
|
...extra,
|
|
515
569
|
}),
|
|
516
570
|
signal: AbortSignal.timeout(3000),
|
|
@@ -532,12 +586,20 @@ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
|
|
|
532
586
|
}
|
|
533
587
|
|
|
534
588
|
async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
535
|
-
// Guard: if no JWT,
|
|
589
|
+
// Guard: if no JWT, try silent auto-setup first, then fall back to device flow.
|
|
536
590
|
if (!getJWT()) {
|
|
591
|
+
// ── Step 1: Silent instant-trial (zero-click) ────────────────────────
|
|
592
|
+
// On the very first tool call, attempt to silently provision a free trial.
|
|
593
|
+
// If it succeeds the user never sees an auth prompt — Brain is just active.
|
|
594
|
+
const provisioned = await silentAutoSetup();
|
|
595
|
+
if (provisioned) {
|
|
596
|
+
return handleTool(name, args); // Transparent re-entry with active credentials
|
|
597
|
+
}
|
|
598
|
+
|
|
537
599
|
void sendAnonymousTelemetry(name);
|
|
538
600
|
|
|
539
|
-
// ── Zero-credential device flow
|
|
540
|
-
// 1st call: start device flow, return code + URL
|
|
601
|
+
// ── Step 2: Zero-credential device flow ─────────────────────────────
|
|
602
|
+
// 1st call after silent setup fails: start device flow, return code + URL
|
|
541
603
|
// 2nd+ calls: poll for token; once authenticated, proceed transparently
|
|
542
604
|
if (_deviceFlow) {
|
|
543
605
|
const result = await pollDeviceFlow(_deviceFlow);
|
|
@@ -611,7 +673,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
611
673
|
}
|
|
612
674
|
|
|
613
675
|
// Delegate brain tools (learn, recall, session, etc.)
|
|
614
|
-
|
|
676
|
+
let brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
|
|
615
677
|
if (brainResult !== null) {
|
|
616
678
|
if (!_firstCallSuccessSent && getJWT()) {
|
|
617
679
|
_firstCallSuccessSent = true;
|
|
@@ -636,28 +698,40 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
636
698
|
if (!isFirstSession && resultText.length > 100) {
|
|
637
699
|
// Existing brain — session_start acts as implicit recall → increment counter.
|
|
638
700
|
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Auto-bootstrap brain_from_git when workspace_path is provided and it hasn't run yet.
|
|
704
|
+
// We check a sentinel key in Valkey so this only fires once per workspace path,
|
|
705
|
+
// not just on the very first session (handles cases where brain was empty but
|
|
706
|
+
// session data existed, or where the user re-installed).
|
|
707
|
+
if (args.workspace_path) {
|
|
642
708
|
try {
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
//
|
|
652
|
-
const
|
|
653
|
-
? (gitBootstrap as { content: { text?: string }[] }).content.map((c) => c.text ?? '').join('\n')
|
|
654
|
-
: String(gitBootstrap);
|
|
709
|
+
const workspacePath = args.workspace_path as string;
|
|
710
|
+
const autoKey = `cachly:brain_from_git:auto_done:${Buffer.from(workspacePath).toString('base64').slice(0, 40)}`;
|
|
711
|
+
const redis = await getConnection(instanceId);
|
|
712
|
+
const alreadyDone = await redis.get(autoKey);
|
|
713
|
+
if (!alreadyDone) {
|
|
714
|
+
// Mark immediately to prevent concurrent runs across multiple MCP tool calls
|
|
715
|
+
await redis.set(autoKey, '1', 'EX', 365 * 86400);
|
|
716
|
+
// Append a placeholder so the user gets immediate feedback, then bootstrap in background.
|
|
717
|
+
// Running brain_from_git synchronously would block session_start for 30-120s on large repos.
|
|
718
|
+
const bgMsg = '\n\n---\n\n🚀 **Auto-indexing your git history in the background** — your Brain will learn from recent commits automatically. No action needed.';
|
|
655
719
|
if (typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult) {
|
|
656
|
-
(brainResult as { content: { type: string; text: string }[] }).content.push({
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
});
|
|
720
|
+
(brainResult as { content: { type: string; text: string }[] }).content.push({ type: 'text', text: bgMsg });
|
|
721
|
+
} else if (typeof brainResult === 'string') {
|
|
722
|
+
brainResult = brainResult + bgMsg;
|
|
660
723
|
}
|
|
724
|
+
// Fire-and-forget — never block the session_start response
|
|
725
|
+
void handleFedbrainTool('brain_from_git', {
|
|
726
|
+
instance_id: instanceId,
|
|
727
|
+
repo_path: workspacePath,
|
|
728
|
+
limit: 50,
|
|
729
|
+
}, getConnection, apiFetch).then((gitBootstrap) => {
|
|
730
|
+
if (gitBootstrap !== null) {
|
|
731
|
+
sendFunnelEvent('brain_from_git', telemetryExtra);
|
|
732
|
+
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
733
|
+
}
|
|
734
|
+
}).catch(() => { /* non-critical */ });
|
|
661
735
|
}
|
|
662
736
|
} catch { /* git bootstrap errors must never break session_start */ }
|
|
663
737
|
}
|
|
@@ -760,28 +834,38 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
760
834
|
}
|
|
761
835
|
|
|
762
836
|
// Decode JWT claims (inspection only, no verification)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
837
|
+
const jwt = getJWT();
|
|
838
|
+
let authInfo: string;
|
|
839
|
+
// API keys (cky_live_... / cky_trial_...) are valid long-lived credentials.
|
|
840
|
+
// Only try to decode as JWT (3-part base64url) if it looks like one.
|
|
841
|
+
if (jwt.startsWith('cky_')) {
|
|
842
|
+
const keyType = jwt.startsWith('cky_trial_') ? 'Trial' : 'Live';
|
|
843
|
+
const keyId = jwt.slice(0, 20) + '…';
|
|
844
|
+
authInfo = `✅ API key (${keyType})\n Key prefix: ${keyId}`;
|
|
845
|
+
} else {
|
|
846
|
+
authInfo = '❌ invalid credential format';
|
|
847
|
+
try {
|
|
848
|
+
const parts = jwt.split('.');
|
|
849
|
+
if (parts.length === 3) {
|
|
850
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
|
|
851
|
+
sub?: string; exp?: number; iss?: string;
|
|
852
|
+
};
|
|
853
|
+
const sub = payload.sub ?? '(unknown)';
|
|
854
|
+
const iss = payload.iss ?? '(unknown)';
|
|
855
|
+
const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
|
|
856
|
+
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
857
|
+
const expired = expTs ? expTs < new Date() : false;
|
|
858
|
+
authInfo = [
|
|
859
|
+
`✅ JWT decoded`,
|
|
860
|
+
` Sub (user ID): ${sub}`,
|
|
861
|
+
` Provider: ${provider}`,
|
|
862
|
+
` Issuer: ${iss}`,
|
|
863
|
+
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
|
|
864
|
+
].join('\n');
|
|
865
|
+
}
|
|
866
|
+
} catch {
|
|
867
|
+
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
782
868
|
}
|
|
783
|
-
} catch {
|
|
784
|
-
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
785
869
|
}
|
|
786
870
|
|
|
787
871
|
// List instances + connectivity
|
|
@@ -923,12 +1007,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
923
1007
|
|| _defaultInstanceId
|
|
924
1008
|
|| process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
925
1009
|
if (instanceId) {
|
|
926
|
-
|
|
1010
|
+
// Fire-and-forget — session management is non-critical and must never block tool calls.
|
|
1011
|
+
// The session_start briefing (which includes brain_from_git) can take 30-120s on large
|
|
1012
|
+
// repos; awaiting it here would freeze the user's first MCP tool call entirely.
|
|
1013
|
+
void autoStartSession(instanceId);
|
|
927
1014
|
}
|
|
928
|
-
} else if (_autoSessionStarting) {
|
|
929
|
-
// Another concurrent call is mid-start — wait for it before proceeding.
|
|
930
|
-
await _autoSessionStarting.catch(() => undefined);
|
|
931
1015
|
}
|
|
1016
|
+
// Note: we intentionally do NOT await _autoSessionStarting for any call.
|
|
1017
|
+
// The session tracker runs in background — tool calls must always proceed immediately.
|
|
932
1018
|
if (!sessionTools.has(name)) _autoSessionToolCount++;
|
|
933
1019
|
|
|
934
1020
|
try {
|
|
@@ -1998,7 +2084,7 @@ if (process.argv[2] === 'status') {
|
|
|
1998
2084
|
if (process.argv[2] === 'health') {
|
|
1999
2085
|
const { existsSync } = await import('node:fs');
|
|
2000
2086
|
const { readFile } = await import('node:fs/promises');
|
|
2001
|
-
const { resolve
|
|
2087
|
+
const { resolve } = await import('node:path');
|
|
2002
2088
|
|
|
2003
2089
|
const cwd = process.cwd();
|
|
2004
2090
|
let passed = 0;
|
|
@@ -2015,6 +2101,9 @@ if (process.argv[2] === 'health') {
|
|
|
2015
2101
|
const jwt = process.env.CACHLY_JWT ?? '';
|
|
2016
2102
|
if (!jwt) {
|
|
2017
2103
|
fail('CACHLY_JWT not set — run: npx @cachly-dev/mcp-server@latest setup');
|
|
2104
|
+
} else if (jwt.startsWith('cky_')) {
|
|
2105
|
+
// Opaque cachly API key — no JWT expiry to check, format is valid by prefix
|
|
2106
|
+
ok('API key set (cky_*** opaque key)');
|
|
2018
2107
|
} else {
|
|
2019
2108
|
try {
|
|
2020
2109
|
const parts = jwt.split('.');
|
|
@@ -2032,7 +2121,7 @@ if (process.argv[2] === 'health') {
|
|
|
2032
2121
|
ok(`JWT valid${minsLeft !== null ? ` (expires in ${Math.floor(minsLeft / 60)}h ${minsLeft % 60}m)` : ''}`);
|
|
2033
2122
|
}
|
|
2034
2123
|
} catch {
|
|
2035
|
-
fail('CACHLY_JWT format invalid (expected JWT with 3 parts)');
|
|
2124
|
+
fail('CACHLY_JWT format invalid (expected a cky_*** API key or a JWT with 3 parts)');
|
|
2036
2125
|
}
|
|
2037
2126
|
}
|
|
2038
2127
|
|
|
@@ -2080,11 +2169,23 @@ if (process.argv[2] === 'health') {
|
|
|
2080
2169
|
signal: AbortSignal.timeout(5000),
|
|
2081
2170
|
});
|
|
2082
2171
|
if (connRes.ok) {
|
|
2083
|
-
const conn = await connRes.json() as { password?: string; tls_enabled?: boolean };
|
|
2084
|
-
|
|
2085
|
-
|
|
2172
|
+
const conn = await connRes.json() as { password?: string; connection_string?: string; tls_enabled?: boolean };
|
|
2173
|
+
// password may be a direct field, or embedded in connection_string as redis://:pass@host:port
|
|
2174
|
+
if (conn.password) {
|
|
2175
|
+
pingPassword = conn.password;
|
|
2176
|
+
} else if (conn.connection_string) {
|
|
2177
|
+
try {
|
|
2178
|
+
const u = new URL(conn.connection_string);
|
|
2179
|
+
if (u.password) pingPassword = decodeURIComponent(u.password);
|
|
2180
|
+
} catch { /* leave pingPassword undefined */ }
|
|
2181
|
+
}
|
|
2182
|
+
pingTls = conn.tls_enabled === true;
|
|
2183
|
+
} else {
|
|
2184
|
+
warn(`Could not fetch Redis credentials (HTTP ${connRes.status}) — connectivity test may fail`);
|
|
2086
2185
|
}
|
|
2087
|
-
} catch {
|
|
2186
|
+
} catch (connErr) {
|
|
2187
|
+
warn(`Could not fetch Redis credentials — ${(connErr as Error).message}`);
|
|
2188
|
+
}
|
|
2088
2189
|
const pingClient = new Redis({
|
|
2089
2190
|
host: inst.host, port: inst.port,
|
|
2090
2191
|
password: pingPassword,
|
|
@@ -2299,7 +2400,7 @@ if (process.argv[2] === 'setup') {
|
|
|
2299
2400
|
if (!keyBody.key) throw new Error('no key in response');
|
|
2300
2401
|
token = keyBody.key; // swap JWT → cky_live_...
|
|
2301
2402
|
console.log(' ✓\n');
|
|
2302
|
-
} catch
|
|
2403
|
+
} catch {
|
|
2303
2404
|
console.log(' (skipped)\n');
|
|
2304
2405
|
// Non-fatal: fall back to using the Keycloak JWT directly.
|
|
2305
2406
|
// It will expire but setup still works for now.
|
|
@@ -2689,7 +2790,7 @@ if (process.argv[2] === 'learn-git') {
|
|
|
2689
2790
|
limit: maxCommits,
|
|
2690
2791
|
});
|
|
2691
2792
|
console.log(result);
|
|
2692
|
-
sendFunnelEvent('brain_from_git', {
|
|
2793
|
+
sendFunnelEvent('brain_from_git', { instance_id: instanceId });
|
|
2693
2794
|
console.log('\n✅ Brain learned from your commits.\n');
|
|
2694
2795
|
// Let fire-and-forget telemetry flush before exit.
|
|
2695
2796
|
await new Promise(r => setTimeout(r, 300));
|
|
@@ -2780,6 +2881,29 @@ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
|
|
|
2780
2881
|
|
|
2781
2882
|
const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
2782
2883
|
|
|
2884
|
+
// ── Per-request MCP Server factory (HTTP / Smithery stateless mode) ──────────
|
|
2885
|
+
// SDK ≥1.9 forbids reusing a stateless transport across requests.
|
|
2886
|
+
// Solution: create a fresh Server+Transport per /mcp request.
|
|
2887
|
+
// The module-level `server` below is used for stdio mode only.
|
|
2888
|
+
function createHttpMcpServer(): Server {
|
|
2889
|
+
const srv = new Server(
|
|
2890
|
+
{ name: 'cachly-mcp', version: CURRENT_VERSION },
|
|
2891
|
+
{ capabilities: { tools: {} } }
|
|
2892
|
+
);
|
|
2893
|
+
srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
2894
|
+
srv.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2895
|
+
const { name, arguments: args } = request.params;
|
|
2896
|
+
try {
|
|
2897
|
+
const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
|
|
2898
|
+
return { content: [{ type: 'text', text }] };
|
|
2899
|
+
} catch (err) {
|
|
2900
|
+
if (err instanceof McpError) throw err;
|
|
2901
|
+
throw new McpError(ErrorCode.InternalError, (err as Error).message);
|
|
2902
|
+
}
|
|
2903
|
+
});
|
|
2904
|
+
return srv;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2783
2907
|
if (httpPort) {
|
|
2784
2908
|
// ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
|
|
2785
2909
|
// Used for Smithery URL deployment: PORT=3000 node dist/index.js
|
|
@@ -2788,15 +2912,19 @@ if (httpPort) {
|
|
|
2788
2912
|
// 2. Smithery config: ?config=base64url({CACHLY_JWT, CACHLY_BRAIN_INSTANCE_ID, ...})
|
|
2789
2913
|
// Falls back to module-level env-derived values when neither is provided.
|
|
2790
2914
|
const { createServer } = await import('node:http');
|
|
2791
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
2792
|
-
await server.connect(transport);
|
|
2793
2915
|
|
|
2794
2916
|
function extractReqCtx(req: import('node:http').IncomingMessage, url: URL): ReqCtx {
|
|
2795
2917
|
let jwtVal = '';
|
|
2796
2918
|
let instId = '';
|
|
2797
2919
|
const auth = req.headers['authorization'];
|
|
2798
|
-
if (typeof auth === 'string' && auth.
|
|
2799
|
-
|
|
2920
|
+
if (typeof auth === 'string' && auth.trim()) {
|
|
2921
|
+
const a = auth.trim();
|
|
2922
|
+
jwtVal = a.toLowerCase().startsWith('bearer ') ? a.slice(7).trim() : a;
|
|
2923
|
+
}
|
|
2924
|
+
// Fallback: raw JWT in custom header (Smithery "header" transport without Bearer prefix)
|
|
2925
|
+
if (!jwtVal) {
|
|
2926
|
+
const rawJwt = req.headers['cachly_jwt'] ?? req.headers['cachly-jwt'] ?? req.headers['x-cachly-jwt'] ?? req.headers['x-api-key'];
|
|
2927
|
+
if (typeof rawJwt === 'string') jwtVal = rawJwt.trim();
|
|
2800
2928
|
}
|
|
2801
2929
|
const hdrInst = req.headers['x-cachly-instance-id'];
|
|
2802
2930
|
if (typeof hdrInst === 'string') instId = hdrInst.trim();
|
|
@@ -2811,6 +2939,15 @@ if (httpPort) {
|
|
|
2811
2939
|
if (!instId && typeof json.CACHLY_BRAIN_INSTANCE_ID === 'string') instId = json.CACHLY_BRAIN_INSTANCE_ID;
|
|
2812
2940
|
} catch { /* malformed config — ignore, fall back to globals */ }
|
|
2813
2941
|
}
|
|
2942
|
+
// Direct query params (Smithery "query" transport, manual clients, etc.)
|
|
2943
|
+
if (!jwtVal) {
|
|
2944
|
+
const q = url.searchParams.get('CACHLY_JWT') ?? url.searchParams.get('cachly_jwt') ?? url.searchParams.get('api_key');
|
|
2945
|
+
if (q) jwtVal = q.trim();
|
|
2946
|
+
}
|
|
2947
|
+
if (!instId) {
|
|
2948
|
+
const q = url.searchParams.get('CACHLY_BRAIN_INSTANCE_ID') ?? url.searchParams.get('cachly_brain_instance_id') ?? url.searchParams.get('instance_id');
|
|
2949
|
+
if (q) instId = q.trim();
|
|
2950
|
+
}
|
|
2814
2951
|
return { jwt: jwtVal, instanceId: instId };
|
|
2815
2952
|
}
|
|
2816
2953
|
|
|
@@ -2830,16 +2967,51 @@ if (httpPort) {
|
|
|
2830
2967
|
}));
|
|
2831
2968
|
return;
|
|
2832
2969
|
}
|
|
2970
|
+
// Static server card — lets Smithery skip live scanning (avoids auth complexity).
|
|
2971
|
+
if (url.pathname === '/.well-known/mcp/server-card.json') {
|
|
2972
|
+
const card = {
|
|
2973
|
+
name: 'cachly AI Brain',
|
|
2974
|
+
version: CURRENT_VERSION,
|
|
2975
|
+
description: 'Persistent AI memory for Claude Code, Cursor, Copilot, Windsurf & friends. Remembers every lesson, fix, and architecture decision forever.',
|
|
2976
|
+
transport: { type: 'streamable-http', url: 'https://mcp.cachly.dev/mcp' },
|
|
2977
|
+
capabilities: { tools: {} },
|
|
2978
|
+
};
|
|
2979
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
2980
|
+
res.end(JSON.stringify(card));
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2833
2983
|
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
2984
|
+
// Handle CORS preflight
|
|
2985
|
+
if (req.method === 'OPTIONS') {
|
|
2986
|
+
res.writeHead(204, {
|
|
2987
|
+
'Access-Control-Allow-Origin': '*',
|
|
2988
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
2989
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, X-Cachly-Instance-Id, cachly_jwt, cachly-jwt, x-cachly-jwt, x-api-key',
|
|
2990
|
+
});
|
|
2991
|
+
res.end();
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2834
2994
|
const ctx = extractReqCtx(req, url);
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2995
|
+
// Create a fresh Server+Transport for each request (stateless multi-tenant mode).
|
|
2996
|
+
// SDK ≥1.9 throws if a stateless transport is reused across requests.
|
|
2997
|
+
const reqServer = createHttpMcpServer();
|
|
2998
|
+
const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
2999
|
+
reqServer.connect(reqTransport).then(() => {
|
|
3000
|
+
reqCtx.run(ctx, () => {
|
|
3001
|
+
reqTransport.handleRequest(req, res).catch((err) => {
|
|
3002
|
+
process.stderr.write(`cachly-mcp HTTP handler error: ${(err as Error).message}\n`);
|
|
3003
|
+
if (!res.headersSent) {
|
|
3004
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3005
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
3006
|
+
}
|
|
3007
|
+
});
|
|
2842
3008
|
});
|
|
3009
|
+
}).catch((err) => {
|
|
3010
|
+
process.stderr.write(`cachly-mcp connect error: ${(err as Error).message}\n`);
|
|
3011
|
+
if (!res.headersSent) {
|
|
3012
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3013
|
+
res.end(JSON.stringify({ error: 'internal_error' }));
|
|
3014
|
+
}
|
|
2843
3015
|
});
|
|
2844
3016
|
return;
|
|
2845
3017
|
}
|
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)
|