@cachly-dev/mcp-server 0.10.70 → 0.10.72
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 +1 -1
- package/dist/src/auth.d.ts +26 -0
- package/dist/src/auth.js +77 -5
- package/dist/src/auth.js.map +1 -1
- package/dist/src/handlers/brain.js +20 -2
- package/dist/src/handlers/brain.js.map +1 -1
- package/dist/src/handlers/team.d.ts +8 -0
- package/dist/src/handlers/team.js +148 -3
- package/dist/src/handlers/team.js.map +1 -1
- package/dist/src/index.js +164 -5
- package/dist/src/index.js.map +1 -1
- package/dist/src/tools.d.ts +56 -0
- package/dist/src/tools.js +50 -0
- package/dist/src/tools.js.map +1 -1
- package/package.json +2 -2
- package/server.json +3 -3
package/dist/src/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
|
|
2
|
+
import { jwtExpiryMs, checkJwt, handleApiError, diagnoseAuth, planAuthHeal } from './auth.js';
|
|
3
3
|
import { handleTcoTool } from './handlers/tco.js';
|
|
4
4
|
import { notify } from './notifier.js';
|
|
5
5
|
import { readFile } from 'node:fs/promises';
|
|
@@ -64,7 +64,7 @@ import { Redis } from 'ioredis';
|
|
|
64
64
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
65
65
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
66
66
|
const _EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
67
|
-
const CURRENT_VERSION = '0.10.
|
|
67
|
+
const CURRENT_VERSION = '0.10.72';
|
|
68
68
|
// Max time to wait for a freshly-provisioned instance to become "running" before
|
|
69
69
|
// giving up. Free-tier provisioning in high-latency regions can take 45–90s, so the
|
|
70
70
|
// old hard-coded 25s caused spurious "instance_not_reachable" on first run.
|
|
@@ -106,6 +106,108 @@ async function resolveDefaultInstanceId() {
|
|
|
106
106
|
catch { /* transient error — will retry after cooldown */ }
|
|
107
107
|
return '';
|
|
108
108
|
}
|
|
109
|
+
// ── Self-Healing Auth ─────────────────────────────────────────────────────────
|
|
110
|
+
// Prevents the silent "0 recalls because the token quietly died" failure mode.
|
|
111
|
+
// When the credential is near expiry we mint a fresh long-lived API key *while the
|
|
112
|
+
// current one is still valid* (no user interaction). When it is already dead we
|
|
113
|
+
// surface a single, actionable instruction instead of degrading silently.
|
|
114
|
+
// Cooldown so a burst of tool calls can't trigger repeated mint attempts.
|
|
115
|
+
let _authHealAttemptedAt = 0;
|
|
116
|
+
const AUTH_HEAL_COOLDOWN_MS = 60_000;
|
|
117
|
+
// Surfaced in session_start / get_api_status so the user is never left guessing.
|
|
118
|
+
let _authDegradedNotice = '';
|
|
119
|
+
/** Persist a (possibly refreshed) API key to ~/.claude/mcp.json so restarts keep it. */
|
|
120
|
+
async function persistApiKeyToConfig(apiKey) {
|
|
121
|
+
try {
|
|
122
|
+
const { writeFile, mkdir, readFile } = await import('node:fs/promises');
|
|
123
|
+
const { existsSync } = await import('node:fs');
|
|
124
|
+
const { resolve, dirname } = await import('node:path');
|
|
125
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
126
|
+
if (!home)
|
|
127
|
+
return;
|
|
128
|
+
const configPath = resolve(home, '.claude', 'mcp.json');
|
|
129
|
+
let cfg = {};
|
|
130
|
+
if (existsSync(configPath)) {
|
|
131
|
+
try {
|
|
132
|
+
cfg = JSON.parse(await readFile(configPath, 'utf-8'));
|
|
133
|
+
}
|
|
134
|
+
catch { /* corrupt — start fresh */ }
|
|
135
|
+
}
|
|
136
|
+
cfg.mcpServers ??= {};
|
|
137
|
+
const existing = cfg.mcpServers['cachly'];
|
|
138
|
+
if (existing) {
|
|
139
|
+
existing.env ??= {};
|
|
140
|
+
existing.env['CACHLY_JWT'] = apiKey;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
cfg.mcpServers['cachly'] = {
|
|
144
|
+
command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
145
|
+
env: { CACHLY_JWT: apiKey },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
149
|
+
await writeFile(configPath, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
150
|
+
}
|
|
151
|
+
catch { /* non-critical — never break a tool call on a filesystem error */ }
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Exchange the current (still-valid) token for a fresh long-lived API key.
|
|
155
|
+
* Returns true on success. Used by self-healing when a token is near expiry.
|
|
156
|
+
*/
|
|
157
|
+
async function refreshApiKey() {
|
|
158
|
+
try {
|
|
159
|
+
const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${JWT}` },
|
|
162
|
+
body: JSON.stringify({ name: 'cachly-mcp-selfheal', scope: 'read_write' }),
|
|
163
|
+
signal: AbortSignal.timeout(8000),
|
|
164
|
+
});
|
|
165
|
+
if (!keyRes.ok)
|
|
166
|
+
return false;
|
|
167
|
+
const body = await keyRes.json();
|
|
168
|
+
if (!body.key)
|
|
169
|
+
return false;
|
|
170
|
+
JWT = body.key;
|
|
171
|
+
setEmbedJwt(body.key);
|
|
172
|
+
void persistApiKeyToConfig(body.key);
|
|
173
|
+
sendFunnelEvent('auth_self_healed', { reason: 'near_expiry_refresh' });
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Run a self-healing pass on the current credential. Returns true if the
|
|
182
|
+
* credential is usable afterwards. Cheap to call (diagnosis is local); only does
|
|
183
|
+
* network work when a refresh is actually warranted, and not more than once per
|
|
184
|
+
* cooldown window.
|
|
185
|
+
*/
|
|
186
|
+
async function selfHealAuth() {
|
|
187
|
+
const d = diagnoseAuth(JWT);
|
|
188
|
+
const action = planAuthHeal(d);
|
|
189
|
+
if (action === 'none') {
|
|
190
|
+
_authDegradedNotice = '';
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
if (action === 'refresh') {
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
if (now - _authHealAttemptedAt >= AUTH_HEAL_COOLDOWN_MS) {
|
|
196
|
+
_authHealAttemptedAt = now;
|
|
197
|
+
const ok = await refreshApiKey();
|
|
198
|
+
if (ok) {
|
|
199
|
+
_authDegradedNotice = '';
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Refresh failed/cooled down but token is still technically usable.
|
|
204
|
+
_authDegradedNotice = d.message;
|
|
205
|
+
return d.usable;
|
|
206
|
+
}
|
|
207
|
+
// action === 'reauth' — credential is dead/missing; can't heal without the user.
|
|
208
|
+
_authDegradedNotice = d.message;
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
109
211
|
let _deviceFlow = null;
|
|
110
212
|
async function startDeviceFlow() {
|
|
111
213
|
const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
|
|
@@ -420,7 +522,15 @@ async function getConnection(instance_id) {
|
|
|
420
522
|
return client;
|
|
421
523
|
}
|
|
422
524
|
// ── API helper ────────────────────────────────────────────────────────────────
|
|
423
|
-
async function apiFetch(path, options = {}) {
|
|
525
|
+
async function apiFetch(path, options = {}, _isRetry = false) {
|
|
526
|
+
// Self-healing pass: proactively refresh a near-expiry token into a long-lived
|
|
527
|
+
// key *before* it can fail. On the first call only (the heal is internally
|
|
528
|
+
// cooldown-guarded, so this is cheap on the hot path).
|
|
529
|
+
if (!_isRetry) {
|
|
530
|
+
const action = planAuthHeal(diagnoseAuth(JWT));
|
|
531
|
+
if (action !== 'none')
|
|
532
|
+
await selfHealAuth();
|
|
533
|
+
}
|
|
424
534
|
checkJwt(JWT);
|
|
425
535
|
const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
|
|
426
536
|
const res = await fetch(`${API_URL}${path}`, {
|
|
@@ -433,6 +543,15 @@ async function apiFetch(path, options = {}) {
|
|
|
433
543
|
},
|
|
434
544
|
});
|
|
435
545
|
if (!res.ok) {
|
|
546
|
+
// A 401 means the token was rejected server-side (e.g. revoked or expired
|
|
547
|
+
// between diagnosis and the call). Try to self-heal once, then retry — so a
|
|
548
|
+
// recoverable credential never silently turns into "0 recalls".
|
|
549
|
+
if (res.status === 401 && !_isRetry) {
|
|
550
|
+
_authHealAttemptedAt = 0; // force the heal to run despite the cooldown
|
|
551
|
+
const healed = await selfHealAuth();
|
|
552
|
+
if (healed)
|
|
553
|
+
return apiFetch(path, options, true);
|
|
554
|
+
}
|
|
436
555
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
437
556
|
const detail = body.error ?? res.statusText;
|
|
438
557
|
handleApiError(res.status, detail);
|
|
@@ -547,7 +666,7 @@ async function handleTool(name, args) {
|
|
|
547
666
|
'',
|
|
548
667
|
'After sign-in: call **any tool again** — your Brain activates instantly.',
|
|
549
668
|
'',
|
|
550
|
-
'✨ Free forever · No credit card ·
|
|
669
|
+
'✨ Free forever · No credit card · 105 MCP tools · GDPR · EU servers',
|
|
551
670
|
].join('\n');
|
|
552
671
|
}
|
|
553
672
|
// Device flow unavailable (network issue) — fall back to manual setup
|
|
@@ -636,6 +755,11 @@ async function handleTool(name, args) {
|
|
|
636
755
|
if (srText.length > 50 && !srText.includes('No lessons found') && !srText.includes('no lessons') && !srText.includes('No matches found')) {
|
|
637
756
|
sendFunnelEvent('recall_best_solution', telemetryExtra);
|
|
638
757
|
}
|
|
758
|
+
// Self-healing: if the credential is degraded, the briefing may be thin or
|
|
759
|
+
// empty ("0 recalls"). Tell the user why, up front, instead of silently.
|
|
760
|
+
if (_authDegradedNotice && typeof brainResult === 'string') {
|
|
761
|
+
return `> ⚠️ **Brain auth degraded** — ${_authDegradedNotice.split('\n')[0]}\n\n${brainResult}`;
|
|
762
|
+
}
|
|
639
763
|
}
|
|
640
764
|
return brainResult;
|
|
641
765
|
}
|
|
@@ -789,11 +913,20 @@ async function handleTool(name, args) {
|
|
|
789
913
|
catch (e) {
|
|
790
914
|
instanceInfo = `\n\n🧠 **Brain Instances:** could not fetch — ${e.message}`;
|
|
791
915
|
}
|
|
916
|
+
// Self-healing diagnosis: a status check is a natural moment to refresh a
|
|
917
|
+
// near-expiry token, and to tell the user plainly if the credential is dying.
|
|
918
|
+
await selfHealAuth();
|
|
919
|
+
const diag = diagnoseAuth(JWT);
|
|
920
|
+
const selfHealLine = diag.state === 'long_lived' ? `🛡️ Self-healing: ✅ long-lived API key (won't expire silently)`
|
|
921
|
+
: diag.state === 'healthy' ? `🛡️ Self-healing: ✅ token valid${diag.expiresInMs ? ` (${Math.round(diag.expiresInMs / 3_600_000)}h left)` : ''}`
|
|
922
|
+
: diag.state === 'near_expiry' ? `🛡️ Self-healing: ♻️ token near expiry — auto-refresh attempted`
|
|
923
|
+
: `🛡️ Self-healing: ⚠️ ${diag.message.split('\n')[0]}`;
|
|
792
924
|
return [
|
|
793
925
|
`📡 **cachly API Status**`,
|
|
794
926
|
``,
|
|
795
927
|
` 🌐 API: ${API_URL}`,
|
|
796
928
|
` 💓 Health: ${healthStatus}`,
|
|
929
|
+
` ${selfHealLine}`,
|
|
797
930
|
``,
|
|
798
931
|
`🔑 **Auth:**`,
|
|
799
932
|
authInfo,
|
|
@@ -1736,7 +1869,7 @@ if (!process.argv[2] && process.stdout.isTTY) {
|
|
|
1736
1869
|
console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest upgrade\x1b[0m ← Check for updates');
|
|
1737
1870
|
console.log('');
|
|
1738
1871
|
console.log(' \x1b[90mWorks with: Claude Code · Cursor · Windsurf · GitHub Copilot · Cline · Zed\x1b[0m');
|
|
1739
|
-
console.log(' \x1b[90mFree forever · GDPR · German servers ·
|
|
1872
|
+
console.log(' \x1b[90mFree forever · GDPR · German servers · 105 MCP tools\x1b[0m');
|
|
1740
1873
|
console.log('');
|
|
1741
1874
|
process.exit(0);
|
|
1742
1875
|
}
|
|
@@ -2487,6 +2620,32 @@ if (process.argv[2] === 'setup') {
|
|
|
2487
2620
|
console.log(` Dashboard: https://cachly.dev/instances/${instance.id}`);
|
|
2488
2621
|
console.log(`\n 📛 Add a live badge to your README:`);
|
|
2489
2622
|
console.log(` npx @cachly-dev/mcp-server@latest badge\n`);
|
|
2623
|
+
// ── Step 6c: Governance bootstrap — assign the first admin role ─────────────
|
|
2624
|
+
// Optional nudge: ask once whether the user wants to establish the role model.
|
|
2625
|
+
// team_assign_role handles idempotency (first call is free; later calls need admin).
|
|
2626
|
+
if (!nonInteractive) {
|
|
2627
|
+
try {
|
|
2628
|
+
console.log('\n👑 Governance (optional — role model: admin · reviewer · contributor · viewer)');
|
|
2629
|
+
console.log('────────────────────────────────────────');
|
|
2630
|
+
const addAdmin = await ask(' Set yourself as the first admin? (y/N): ', 'N');
|
|
2631
|
+
if (addAdmin.toLowerCase() === 'y') {
|
|
2632
|
+
const adminHandle = await ask(' Your handle (e.g. "alice"): ');
|
|
2633
|
+
if (adminHandle.trim()) {
|
|
2634
|
+
const assignOut = await handleTool('team_assign_role', {
|
|
2635
|
+
instance_id: instance.id, handle: adminHandle.trim(), role: 'admin',
|
|
2636
|
+
}).catch(() => '');
|
|
2637
|
+
if (assignOut) {
|
|
2638
|
+
console.log(` ✅ ${adminHandle.trim()} is now admin. Invite teammates:\n`);
|
|
2639
|
+
console.log(` team_assign_role(instance_id="${instance.id}", handle="<teammate>", role="contributor", assigned_by="${adminHandle.trim()}")\n`);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
else {
|
|
2644
|
+
console.log(' Skipped — run team_assign_role at any time.\n');
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
catch { /* non-critical */ }
|
|
2648
|
+
}
|
|
2490
2649
|
// ── Step 7: Email opt-in (non-blocking — at the very end) ────────────────
|
|
2491
2650
|
if (!nonInteractive) {
|
|
2492
2651
|
const email = await ask(' 📬 Stay in the loop? Email for release notes [Enter to skip]: ');
|