@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/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.68';
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 · 102 MCP tools · GDPR · EU servers',
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 · 102 MCP tools\x1b[0m');
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]: ');