@lifeaitools/clauth 0.3.11 → 0.4.0

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.
@@ -221,32 +221,36 @@ const KEY_URLS = {
221
221
  };
222
222
 
223
223
  // ── OAuth import config ─────────────────────
224
- // OAuth services with atomic fields each field saved as clauth.<service>.<key>
225
- // No JSON blob parsing. Each field is its own vault secret.
226
- const OAUTH_FIELDS = {
227
- "gmail": [
228
- { key: "client_id", label: "Client ID", hint: "From Google Cloud Console → APIs & Services → Credentials → OAuth client", required: true },
229
- { key: "client_secret", label: "Client Secret", hint: "From Google Cloud Console OAuth client", required: true },
230
- { key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground or your app's auth callback", required: true },
231
- { key: "from_address", label: "From Address", hint: "Gmail address to send from (e.g. dave@life.ai)", required: false },
232
- ]
224
+ // Services where Google downloads a JSON file. jsonFields = keys to extract
225
+ // from the downloaded JSON (top-level or under an "installed"/"web" wrapper).
226
+ // extra = additional fields the user must provide separately.
227
+ const OAUTH_IMPORT = {
228
+ "gmail": {
229
+ jsonFields: ["client_id", "client_secret"],
230
+ extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground or your app's auth callback" }]
231
+ }
233
232
  };
234
233
 
235
234
  function renderSetPanel(name) {
236
- const fields = OAUTH_FIELDS[name];
237
- if (fields) {
238
- const fieldsHtml = fields.map(f => \`
235
+ const imp = OAUTH_IMPORT[name];
236
+ if (imp) {
237
+ const extraHtml = imp.extra.map(f => \`
239
238
  <div class="oauth-field">
240
- <label class="oauth-label">\${f.label}\${f.required ? "" : " <span style='color:#475569'>(optional)</span>"}</label>
239
+ <label class="oauth-label">\${f.label}</label>
241
240
  \${f.hint ? \`<div class="oauth-hint">\${f.hint}</div>\` : ""}
242
241
  <input type="text" class="oauth-input" id="ofield-\${name}-\${f.key}" placeholder="Paste \${f.label}…" spellcheck="false" autocomplete="off">
243
242
  </div>
244
243
  \`).join("");
245
244
  return \`
246
245
  <div class="set-panel" id="set-panel-\${name}">
247
- <label>Set <strong>\${name}</strong> credentials — paste directly from source, never in chat</label>
246
+ <label>Set <strong>\${name}</strong> credentials — paste directly from Google, never in chat</label>
248
247
  <div class="oauth-fields">
249
- \${fieldsHtml}
248
+ <div class="oauth-field">
249
+ <label class="oauth-label">OAuth JSON from Google Cloud Console</label>
250
+ <div class="oauth-hint">Download from APIs & Services → Credentials → your OAuth client → ↓ Download JSON</div>
251
+ <textarea class="set-input" id="ofield-\${name}-json" placeholder='{"installed":{"client_id":"…","client_secret":"…",...}}' spellcheck="false" rows="3"></textarea>
252
+ </div>
253
+ \${extraHtml}
250
254
  </div>
251
255
  <div class="set-foot">
252
256
  <button class="btn btn-save" onclick="saveKey('\${name}')">Save</button>
@@ -449,68 +453,63 @@ function toggleSet(name) {
449
453
 
450
454
  async function saveKey(name) {
451
455
  const msg = document.getElementById("set-msg-" + name);
452
- const fields = OAUTH_FIELDS[name];
453
-
454
- if (fields) {
455
- // Validate required fields first
456
- for (const f of fields) {
457
- if (!f.required) continue;
458
- const el = document.getElementById("ofield-" + name + "-" + f.key);
459
- if (!el || !el.value.trim()) {
460
- msg.className = "set-msg fail"; msg.textContent = f.label + " is required."; return;
461
- }
456
+ const imp = OAUTH_IMPORT[name];
457
+ let value;
458
+
459
+ if (imp) {
460
+ const jsonEl = document.getElementById("ofield-" + name + "-json");
461
+ const raw = jsonEl ? jsonEl.value.trim() : "";
462
+ if (!raw) { msg.className = "set-msg fail"; msg.textContent = "Paste the OAuth JSON first."; return; }
463
+ let parsed;
464
+ try { parsed = JSON.parse(raw); } catch { msg.className = "set-msg fail"; msg.textContent = "Invalid JSON copy the full file content."; return; }
465
+ // Google wraps fields under "installed" or "web"
466
+ const src = parsed.installed || parsed.web || parsed;
467
+ const obj = {};
468
+ for (const k of imp.jsonFields) {
469
+ if (!src[k]) { msg.className = "set-msg fail"; msg.textContent = k + " not found in JSON."; return; }
470
+ obj[k] = src[k];
462
471
  }
463
- // Save each field as an atomic vault key: clauth.<service>.<key>
464
- msg.className = "set-msg"; msg.textContent = "Saving…";
465
- try {
466
- for (const f of fields) {
467
- const el = document.getElementById("ofield-" + name + "-" + f.key);
468
- const v = el ? el.value.trim() : "";
469
- if (!v) continue; // skip optional empty fields
470
- const r = await fetch(BASE + "/set/" + name + "." + f.key, {
471
- method: "POST",
472
- headers: { "Content-Type": "application/json" },
473
- body: JSON.stringify({ value: v })
474
- }).then(r => r.json());
475
- if (r.locked) { showLockScreen(); return; }
476
- if (r.error) throw new Error(r.error);
477
- }
478
- msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
479
- fields.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
480
- } catch (e) {
481
- msg.className = "set-msg fail"; msg.textContent = e.message || "Save failed";
482
- return;
472
+ for (const f of imp.extra) {
473
+ const el = document.getElementById("ofield-" + name + "-" + f.key);
474
+ const v = el ? el.value.trim() : "";
475
+ if (!v) { msg.className = "set-msg fail"; msg.textContent = f.label + " is required."; return; }
476
+ obj[f.key] = v;
483
477
  }
478
+ value = JSON.stringify(obj);
484
479
  } else {
485
480
  const input = document.getElementById("set-input-" + name);
486
- const value = input ? input.value.trim() : "";
481
+ value = input ? input.value.trim() : "";
487
482
  if (!value) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
483
+ }
488
484
 
489
- msg.className = "set-msg"; msg.textContent = "Saving…";
490
- try {
491
- const r = await fetch(BASE + "/set/" + name, {
492
- method: "POST",
493
- headers: { "Content-Type": "application/json" },
494
- body: JSON.stringify({ value })
495
- }).then(r => r.json());
496
-
497
- if (r.locked) { showLockScreen(); return; }
498
- if (r.error) throw new Error(r.error);
499
- msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
485
+ msg.className = "set-msg"; msg.textContent = "Saving…";
486
+ try {
487
+ const r = await fetch(BASE + "/set/" + name, {
488
+ method: "POST",
489
+ headers: { "Content-Type": "application/json" },
490
+ body: JSON.stringify({ value })
491
+ }).then(r => r.json());
492
+
493
+ if (r.locked) { showLockScreen(); return; }
494
+ if (r.error) throw new Error(r.error);
495
+ msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
496
+ if (imp) {
497
+ const jsonEl = document.getElementById("ofield-" + name + "-json");
498
+ if (jsonEl) jsonEl.value = "";
499
+ imp.extra.forEach(f => { const el = document.getElementById("ofield-" + name + "-" + f.key); if (el) el.value = ""; });
500
+ } else {
500
501
  const inp = document.getElementById("set-input-" + name);
501
502
  if (inp) inp.value = "";
502
- } catch (e) {
503
- msg.className = "set-msg fail"; msg.textContent = e.message || "Save failed";
504
- return;
505
503
  }
504
+ const dot = document.getElementById("sdot-" + name);
505
+ if (dot) { dot.className = "status-dot"; dot.title = ""; }
506
+ setTimeout(() => {
507
+ document.getElementById("set-panel-" + name).style.display = "none";
508
+ msg.textContent = "";
509
+ }, 1800);
510
+ } catch (e) {
511
+ msg.className = "set-msg fail"; msg.textContent = "✗ " + e.message;
506
512
  }
507
-
508
- const dot = document.getElementById("sdot-" + name);
509
- if (dot) { dot.className = "status-dot"; dot.title = ""; }
510
- setTimeout(() => {
511
- document.getElementById("set-panel-" + name).style.display = "none";
512
- msg.textContent = "";
513
- }, 1800);
514
513
  }
515
514
 
516
515
  // ── Enable / Disable service ────────────────
@@ -772,7 +771,7 @@ function createServer(initPassword, whitelist, port) {
772
771
  }
773
772
 
774
773
  // GET /get/:service
775
- const getMatch = reqPath.match(/^\/get\/([a-zA-Z0-9_.-]+)$/);
774
+ const getMatch = reqPath.match(/^\/get\/([a-zA-Z0-9_-]+)$/);
776
775
  if (method === "GET" && getMatch) {
777
776
  if (lockedGuard(res)) return;
778
777
  const service = getMatch[1].toLowerCase();
@@ -918,7 +917,7 @@ function createServer(initPassword, whitelist, port) {
918
917
  }
919
918
 
920
919
  // POST /set/:service — write a new key value into vault
921
- const setMatch = reqPath.match(/^\/set\/([a-zA-Z0-9_.-]+)$/);
920
+ const setMatch = reqPath.match(/^\/set\/([a-zA-Z0-9_-]+)$/);
922
921
  if (method === "POST" && setMatch) {
923
922
  if (lockedGuard(res)) return;
924
923
  const service = setMatch[1].toLowerCase();
@@ -1195,19 +1194,460 @@ async function actionForeground(opts) {
1195
1194
  });
1196
1195
  }
1197
1196
 
1197
+ // ── MCP stdio server ──────────────────────────────────────────
1198
+ // JSON-RPC 2.0 over stdin/stdout for Claude Code integration.
1199
+ // Reuses the same auth model as the HTTP daemon.
1200
+ // Secrets are delivered via temp files — never in the MCP response.
1201
+
1202
+ import { createInterface } from "readline";
1203
+ import { execSync } from "child_process";
1204
+
1205
+ const ENV_MAP = {
1206
+ "github": "GITHUB_TOKEN",
1207
+ "supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
1208
+ "supabase-service": "SUPABASE_SERVICE_ROLE_KEY",
1209
+ "supabase-db": "SUPABASE_DB_URL",
1210
+ "vercel": "VERCEL_TOKEN",
1211
+ "anthropic": "ANTHROPIC_API_KEY",
1212
+ "cloudflare": "CLOUDFLARE_API_TOKEN",
1213
+ "r2": "R2_ACCESS_KEY_ID",
1214
+ "r2-bucket": "R2_BUCKET_NAME",
1215
+ "neo4j": "NEO4J_URI",
1216
+ "rocketreach": "ROCKETREACH_API_KEY",
1217
+ "npm": "NPM_TOKEN",
1218
+ "namecheap": "NAMECHEAP_API_KEY",
1219
+ "gmail": "GMAIL_CREDENTIALS",
1220
+ };
1221
+
1222
+ const MCP_TOOLS = [
1223
+ {
1224
+ name: "clauth_ping",
1225
+ description: "Check if the vault is locked or unlocked, show failure count",
1226
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1227
+ },
1228
+ {
1229
+ name: "clauth_unlock",
1230
+ description: "Unlock the vault with the master password (password stays in MCP server memory only)",
1231
+ inputSchema: { type: "object", properties: { password: { type: "string", description: "clauth master password" } }, required: ["password"], additionalProperties: false }
1232
+ },
1233
+ {
1234
+ name: "clauth_lock",
1235
+ description: "Lock the vault — clears password from memory",
1236
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1237
+ },
1238
+ {
1239
+ name: "clauth_status",
1240
+ description: "List all services with type, enabled state, key presence, and last retrieval time",
1241
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1242
+ },
1243
+ {
1244
+ name: "clauth_list",
1245
+ description: "List registered service names",
1246
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1247
+ },
1248
+ {
1249
+ name: "clauth_get",
1250
+ description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
1251
+ inputSchema: {
1252
+ type: "object",
1253
+ properties: {
1254
+ service: { type: "string", description: "Service name (e.g. github, anthropic, vercel)" },
1255
+ target: { type: "string", enum: ["file", "clipboard", "stdout"], default: "file", description: "Where to deliver the secret. 'file' writes a temp file, 'clipboard' copies to clipboard, 'stdout' returns the value in the response (DANGEROUS — enters transcript)" }
1256
+ },
1257
+ required: ["service"],
1258
+ additionalProperties: false
1259
+ }
1260
+ },
1261
+ {
1262
+ name: "clauth_inject",
1263
+ description: "Bulk retrieve multiple services into a single sourceable env file. Usage: source the returned file path before running commands.",
1264
+ inputSchema: {
1265
+ type: "object",
1266
+ properties: {
1267
+ services: { type: "array", items: { type: "string" }, description: "Service names to inject (e.g. ['github', 'vercel', 'anthropic'])" }
1268
+ },
1269
+ required: ["services"],
1270
+ additionalProperties: false
1271
+ }
1272
+ },
1273
+ {
1274
+ name: "clauth_enable",
1275
+ description: "Enable a service in the vault",
1276
+ inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"], additionalProperties: false }
1277
+ },
1278
+ {
1279
+ name: "clauth_disable",
1280
+ description: "Disable a service in the vault",
1281
+ inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"], additionalProperties: false }
1282
+ },
1283
+ {
1284
+ name: "clauth_test",
1285
+ description: "Test the HMAC handshake without retrieving any keys",
1286
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
1287
+ },
1288
+ {
1289
+ name: "clauth_scrub",
1290
+ description: "Scrub secrets from Claude Code transcript .jsonl files",
1291
+ inputSchema: {
1292
+ type: "object",
1293
+ properties: {
1294
+ target: { type: "string", enum: ["latest", "all"], default: "latest", description: "Scrub the most recent transcript or all transcripts" }
1295
+ },
1296
+ additionalProperties: false
1297
+ }
1298
+ },
1299
+ ];
1300
+
1301
+ function writeTempSecret(service, value) {
1302
+ const filePath = path.join(os.tmpdir(), `.clauth-${service}`);
1303
+ fs.writeFileSync(filePath, value, { mode: 0o600 });
1304
+ setTimeout(() => { try { fs.unlinkSync(filePath); } catch {} }, 30_000);
1305
+ return filePath;
1306
+ }
1307
+
1308
+ function copyToClipboard(value) {
1309
+ const platform = os.platform();
1310
+ if (platform === "win32") {
1311
+ execSync("clip", { input: value, stdio: ["pipe", "pipe", "pipe"] });
1312
+ } else if (platform === "darwin") {
1313
+ execSync("pbcopy", { input: value, stdio: ["pipe", "pipe", "pipe"] });
1314
+ } else {
1315
+ execSync("xclip -selection clipboard", { input: value, stdio: ["pipe", "pipe", "pipe"] });
1316
+ }
1317
+ }
1318
+
1319
+ function mcpResult(text) {
1320
+ return { content: [{ type: "text", text }] };
1321
+ }
1322
+
1323
+ function mcpError(text) {
1324
+ return { content: [{ type: "text", text }], isError: true };
1325
+ }
1326
+
1327
+ async function handleMcpTool(vault, name, args) {
1328
+ switch (name) {
1329
+ case "clauth_ping": {
1330
+ return mcpResult(
1331
+ vault.password
1332
+ ? `unlocked | pid: ${process.pid} | failures: ${vault.failCount}/${vault.MAX_FAILS}`
1333
+ : `locked | pid: ${process.pid} | failures: ${vault.failCount}/${vault.MAX_FAILS}`
1334
+ );
1335
+ }
1336
+
1337
+ case "clauth_unlock": {
1338
+ const pw = args.password;
1339
+ if (!pw) return mcpError("password is required");
1340
+ try {
1341
+ const { token, timestamp } = deriveToken(pw, vault.machineHash);
1342
+ const result = await api.test(pw, vault.machineHash, token, timestamp);
1343
+ if (result.error) return mcpError(`Unlock failed: ${result.error}`);
1344
+ vault.password = pw;
1345
+ return mcpResult("Vault unlocked");
1346
+ } catch (err) {
1347
+ return mcpError(`Unlock failed: ${err.message}`);
1348
+ }
1349
+ }
1350
+
1351
+ case "clauth_lock": {
1352
+ vault.password = null;
1353
+ return mcpResult("Vault locked — password cleared from memory");
1354
+ }
1355
+
1356
+ case "clauth_status": {
1357
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1358
+ try {
1359
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1360
+ const result = await api.status(vault.password, vault.machineHash, token, timestamp);
1361
+ if (result.error) return mcpError(result.error);
1362
+ let services = result.services || [];
1363
+ if (vault.whitelist) {
1364
+ services = services.filter(s => vault.whitelist.includes(s.name.toLowerCase()));
1365
+ }
1366
+ const lines = ["SERVICE TYPE STATUS KEY LAST RETRIEVED",
1367
+ "--- ---- ------ --- --------------"];
1368
+ for (const s of services) {
1369
+ const status = s.enabled ? "ACTIVE" : (s.vault_key ? "SUSPENDED" : "NO KEY");
1370
+ const hasKey = s.vault_key ? "yes" : "—";
1371
+ const lastGet = s.last_retrieved ? new Date(s.last_retrieved).toLocaleDateString() : "never";
1372
+ lines.push(`${s.name.padEnd(20)} ${(s.key_type || "").padEnd(12)} ${status.padEnd(12)} ${hasKey.padEnd(6)} ${lastGet}`);
1373
+ }
1374
+ return mcpResult(lines.join("\n"));
1375
+ } catch (err) {
1376
+ return mcpError(err.message);
1377
+ }
1378
+ }
1379
+
1380
+ case "clauth_list": {
1381
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1382
+ try {
1383
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1384
+ const result = await api.status(vault.password, vault.machineHash, token, timestamp);
1385
+ if (result.error) return mcpError(result.error);
1386
+ let services = result.services || [];
1387
+ if (vault.whitelist) {
1388
+ services = services.filter(s => vault.whitelist.includes(s.name.toLowerCase()));
1389
+ }
1390
+ return mcpResult(services.map(s => s.name).join(", "));
1391
+ } catch (err) {
1392
+ return mcpError(err.message);
1393
+ }
1394
+ }
1395
+
1396
+ case "clauth_get": {
1397
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1398
+ const service = (args.service || "").toLowerCase();
1399
+ const target = args.target || "file";
1400
+ if (!service) return mcpError("service is required");
1401
+ if (vault.whitelist && !vault.whitelist.includes(service)) {
1402
+ return mcpError(`Service '${service}' not in whitelist`);
1403
+ }
1404
+ try {
1405
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1406
+ const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
1407
+ if (result.error) return mcpError(result.error);
1408
+ const value = typeof result.value === "string" ? result.value : JSON.stringify(result.value);
1409
+
1410
+ if (target === "clipboard") {
1411
+ try {
1412
+ copyToClipboard(value);
1413
+ return mcpResult(`${service} copied to clipboard`);
1414
+ } catch (err) {
1415
+ return mcpError(`Clipboard failed: ${err.message}. Use target 'file' instead.`);
1416
+ }
1417
+ }
1418
+
1419
+ if (target === "stdout") {
1420
+ return {
1421
+ content: [{ type: "text", text: `WARNING: secret in response — will appear in transcript. Use 'file' or 'clipboard' mode instead.\n\n${value}` }],
1422
+ isError: true
1423
+ };
1424
+ }
1425
+
1426
+ // Default: file
1427
+ const envVar = ENV_MAP[service] || service.toUpperCase().replace(/-/g, "_");
1428
+ const filePath = writeTempSecret(service, value);
1429
+ return mcpResult(`${service} → ${filePath} (auto-deletes in 30s)\nEnv var: ${envVar}\nUsage: export ${envVar}=$(cat ${filePath.replace(/\\/g, "/")})`);
1430
+ } catch (err) {
1431
+ return mcpError(err.message);
1432
+ }
1433
+ }
1434
+
1435
+ case "clauth_inject": {
1436
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1437
+ const services = args.services || [];
1438
+ if (!services.length) return mcpError("services array is required");
1439
+
1440
+ const lines = [];
1441
+ const errors = [];
1442
+ for (const svc of services) {
1443
+ const service = svc.toLowerCase();
1444
+ if (vault.whitelist && !vault.whitelist.includes(service)) {
1445
+ errors.push(`${service}: not in whitelist`);
1446
+ continue;
1447
+ }
1448
+ try {
1449
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1450
+ const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
1451
+ if (result.error) { errors.push(`${service}: ${result.error}`); continue; }
1452
+ const value = typeof result.value === "string" ? result.value : JSON.stringify(result.value);
1453
+ const envVar = ENV_MAP[service] || service.toUpperCase().replace(/-/g, "_");
1454
+ lines.push(`export ${envVar}="${value.replace(/"/g, '\\"')}"`);
1455
+ } catch (err) {
1456
+ errors.push(`${service}: ${err.message}`);
1457
+ }
1458
+ }
1459
+
1460
+ if (!lines.length) return mcpError(`No services retrieved:\n${errors.join("\n")}`);
1461
+
1462
+ const envFilePath = path.join(os.tmpdir(), ".clauth-env");
1463
+ fs.writeFileSync(envFilePath, lines.join("\n") + "\n", { mode: 0o600 });
1464
+ setTimeout(() => { try { fs.unlinkSync(envFilePath); } catch {} }, 30_000);
1465
+
1466
+ let msg = `${lines.length} service(s) → ${envFilePath.replace(/\\/g, "/")} (auto-deletes in 30s)\nUsage: source ${envFilePath.replace(/\\/g, "/")}`;
1467
+ if (errors.length) msg += `\n\nErrors:\n${errors.join("\n")}`;
1468
+ return mcpResult(msg);
1469
+ }
1470
+
1471
+ case "clauth_enable": {
1472
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1473
+ const service = (args.service || "").toLowerCase();
1474
+ if (!service) return mcpError("service is required");
1475
+ try {
1476
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1477
+ const result = await api.enable(vault.password, vault.machineHash, token, timestamp, service, true);
1478
+ if (result.error) return mcpError(result.error);
1479
+ return mcpResult(`${service} enabled`);
1480
+ } catch (err) {
1481
+ return mcpError(err.message);
1482
+ }
1483
+ }
1484
+
1485
+ case "clauth_disable": {
1486
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1487
+ const service = (args.service || "").toLowerCase();
1488
+ if (!service) return mcpError("service is required");
1489
+ try {
1490
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1491
+ const result = await api.enable(vault.password, vault.machineHash, token, timestamp, service, false);
1492
+ if (result.error) return mcpError(result.error);
1493
+ return mcpResult(`${service} disabled`);
1494
+ } catch (err) {
1495
+ return mcpError(err.message);
1496
+ }
1497
+ }
1498
+
1499
+ case "clauth_test": {
1500
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
1501
+ try {
1502
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
1503
+ const result = await api.test(vault.password, vault.machineHash, token, timestamp);
1504
+ if (result.error) return mcpError(`FAIL: ${result.error}`);
1505
+ return mcpResult(`PASS — machine: ${vault.machineHash.slice(0, 16)}... | window: ${new Date(result.timestamp).toISOString()}`);
1506
+ } catch (err) {
1507
+ return mcpError(`FAIL: ${err.message}`);
1508
+ }
1509
+ }
1510
+
1511
+ case "clauth_scrub": {
1512
+ const target = args.target || "latest";
1513
+ try {
1514
+ const { runScrub: doScrub } = await import("./scrub.js");
1515
+ // runScrub writes to stdout via chalk — capture isn't clean in MCP mode.
1516
+ // Call it and trust it works; report success.
1517
+ await doScrub(target === "all" ? "all" : undefined, {});
1518
+ return mcpResult(`Scrub complete (target: ${target})`);
1519
+ } catch (err) {
1520
+ return mcpError(`Scrub failed: ${err.message}`);
1521
+ }
1522
+ }
1523
+
1524
+ default:
1525
+ return mcpError(`Unknown tool: ${name}`);
1526
+ }
1527
+ }
1528
+
1529
+ function createMcpServer(initPassword, whitelist) {
1530
+ // Ensure wmic is reachable — bash shells on Windows may not have
1531
+ // C:\Windows\System32\Wbem on PATH, which getMachineHash() needs.
1532
+ if (os.platform() === "win32" && !process.env.PATH?.includes("Wbem")) {
1533
+ process.env.PATH = (process.env.PATH || "") + ";C:\\Windows\\System32\\Wbem";
1534
+ }
1535
+
1536
+ // Lazy-init machineHash — defer to first tool call that needs auth.
1537
+ let _machineHash = null;
1538
+ function ensureMachineHash() {
1539
+ if (!_machineHash) _machineHash = getMachineHash();
1540
+ return _machineHash;
1541
+ }
1542
+
1543
+ const vault = {
1544
+ password: initPassword || null,
1545
+ get machineHash() { return ensureMachineHash(); },
1546
+ whitelist,
1547
+ failCount: 0,
1548
+ MAX_FAILS: 3,
1549
+ };
1550
+
1551
+ const rl = createInterface({ input: process.stdin, terminal: false });
1552
+
1553
+ function send(msg) {
1554
+ process.stdout.write(JSON.stringify(msg) + "\n");
1555
+ }
1556
+
1557
+ rl.on("line", async (line) => {
1558
+ let msg;
1559
+ try {
1560
+ msg = JSON.parse(line);
1561
+ } catch {
1562
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
1563
+ return;
1564
+ }
1565
+
1566
+ const id = msg.id;
1567
+
1568
+ // notifications (no id) — handle initialized, etc.
1569
+ if (msg.method === "notifications/initialized" || msg.method === "initialized") {
1570
+ return; // no response needed for notifications
1571
+ }
1572
+
1573
+ if (msg.method === "initialize") {
1574
+ return send({
1575
+ jsonrpc: "2.0", id,
1576
+ result: {
1577
+ protocolVersion: "2024-11-05",
1578
+ serverInfo: { name: "clauth", version: VERSION },
1579
+ capabilities: { tools: {} }
1580
+ }
1581
+ });
1582
+ }
1583
+
1584
+ if (msg.method === "tools/list") {
1585
+ return send({
1586
+ jsonrpc: "2.0", id,
1587
+ result: { tools: MCP_TOOLS }
1588
+ });
1589
+ }
1590
+
1591
+ if (msg.method === "tools/call") {
1592
+ const { name, arguments: args } = msg.params || {};
1593
+ try {
1594
+ const result = await handleMcpTool(vault, name, args || {});
1595
+ return send({ jsonrpc: "2.0", id, result });
1596
+ } catch (err) {
1597
+ return send({ jsonrpc: "2.0", id, result: mcpError(`Internal error: ${err.message}`) });
1598
+ }
1599
+ }
1600
+
1601
+ // Unknown method
1602
+ send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${msg.method}` } });
1603
+ });
1604
+
1605
+ rl.on("close", () => {
1606
+ process.exit(0);
1607
+ });
1608
+
1609
+ // Prevent unhandled errors from crashing the MCP server
1610
+ process.on("uncaughtException", (err) => {
1611
+ const msg = `[${new Date().toISOString()}] MCP uncaught: ${err.message}\n`;
1612
+ try { fs.appendFileSync(LOG_FILE, msg); } catch {}
1613
+ });
1614
+ }
1615
+
1616
+ async function actionMcp(opts) {
1617
+ const password = opts.pw || null;
1618
+ const whitelist = opts.services
1619
+ ? opts.services.split(",").map(s => s.trim().toLowerCase())
1620
+ : null;
1621
+
1622
+ if (password) {
1623
+ try {
1624
+ await verifyAuth(password);
1625
+ } catch (err) {
1626
+ // Can't use stderr for errors in MCP mode without careful framing
1627
+ // Write to log file instead
1628
+ const msg = `[${new Date().toISOString()}] MCP auth failed: ${err.message}\n`;
1629
+ try { fs.appendFileSync(LOG_FILE, msg); } catch {}
1630
+ process.exit(1);
1631
+ }
1632
+ }
1633
+
1634
+ createMcpServer(password, whitelist);
1635
+ }
1636
+
1198
1637
  // ── Export ────────────────────────────────────────────────────
1199
1638
  export async function runServe(opts) {
1200
1639
  const action = opts.action || "foreground";
1201
1640
 
1202
1641
  switch (action) {
1203
- case "start": return actionStart(opts);
1204
- case "stop": return actionStop();
1205
- case "restart": return actionRestart(opts);
1206
- case "ping": return actionPing();
1642
+ case "start": return actionStart(opts);
1643
+ case "stop": return actionStop();
1644
+ case "restart": return actionRestart(opts);
1645
+ case "ping": return actionPing();
1207
1646
  case "foreground": return actionForeground(opts);
1647
+ case "mcp": return actionMcp(opts);
1208
1648
  default:
1209
1649
  console.log(chalk.red(`\n Unknown serve action: ${action}`));
1210
- console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground\n"));
1650
+ console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp\n"));
1211
1651
  process.exit(1);
1212
1652
  }
1213
1653
  }