@lifeaitools/clauth 1.5.84 → 1.7.1

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.
@@ -17,10 +17,17 @@ import ora from "ora";
17
17
  import { execSync as execSyncTop } from "child_process";
18
18
  import Conf from "conf";
19
19
  import { getConfOptions } from "../conf-path.js";
20
- import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename } from "node:fs/promises";
20
+ import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename, cp } from "node:fs/promises";
21
21
  import fg from "fast-glob";
22
22
  import { rgPath } from "@vscode/ripgrep";
23
23
  import { createStudioDebugRuntime } from "../studio-debug.js";
24
+ import { writeCredentialWithRecovery } from "../recovery.js";
25
+ import * as fsGit from "../lib/fs-git.js";
26
+ import {
27
+ getWatchdogStatuses,
28
+ readWatchdogEvents,
29
+ restartWatchdogService,
30
+ } from "../watchdog-registry.js";
24
31
 
25
32
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
33
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
@@ -309,9 +316,14 @@ function createRotationEngine(password, machineHash, logFile) {
309
316
  const result = await config.rotate(currentKey);
310
317
  if (!result.newKey) throw new Error("Rotation returned no new key");
311
318
 
312
- // Write new key to vault
313
- const { token: wt, timestamp: wts } = deriveToken(password, machineHash);
314
- const writeResult = await api.write(password, machineHash, wt, wts, serviceName, result.newKey);
319
+ // Write new key to vault after preserving an encrypted recovery snapshot.
320
+ const { result: writeResult } = await writeCredentialWithRecovery({
321
+ password,
322
+ machineHash,
323
+ service: serviceName,
324
+ value: result.newKey,
325
+ logFile,
326
+ });
315
327
  if (writeResult.error) throw new Error(`Write failed: ${writeResult.error}`);
316
328
 
317
329
  // Update expiry in state
@@ -412,6 +424,26 @@ const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
412
424
  const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
413
425
  const LIVE_PORT = 52437;
414
426
  const STAGED_PORT = 52438;
427
+ const WRITE_TOKEN_BYTES = 32;
428
+ const WRITE_TOKEN_TTL_MS = 10 * 60 * 1000;
429
+
430
+ function makeWriteToken() {
431
+ return {
432
+ token: crypto.randomBytes(WRITE_TOKEN_BYTES).toString("base64url"),
433
+ expiresAt: Date.now() + WRITE_TOKEN_TTL_MS,
434
+ };
435
+ }
436
+
437
+ function validateWriteToken(req, writeSession) {
438
+ if (!writeSession?.token || Date.now() > writeSession.expiresAt) return false;
439
+ const header = req.headers["x-clauth-write-token"] || req.headers.authorization;
440
+ const token = Array.isArray(header) ? header[0] : header;
441
+ if (!token) return false;
442
+ const supplied = String(token).startsWith("Bearer ") ? String(token).slice(7) : String(token);
443
+ const a = Buffer.from(supplied);
444
+ const b = Buffer.from(writeSession.token);
445
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
446
+ }
415
447
 
416
448
  // ── PID helpers ──────────────────────────────────────────────
417
449
  function readPid() {
@@ -607,14 +639,14 @@ function dashboardHtml(port, whitelist, isStaged = false) {
607
639
  .project-tab{background:none;border:none;border-bottom:2px solid transparent;color:#64748b;padding:8px 16px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap;transition:all .15s}
608
640
  .project-tab:hover{color:#94a3b8;background:rgba(59,130,246,.05)}
609
641
  .project-tab.active{color:#60a5fa;border-bottom-color:#3b82f6;background:rgba(59,130,246,.08)}
610
- .project-tab .tab-count{font-size:.7rem;color:#475569;margin-left:4px;font-weight:400}
611
- .project-tab.active .tab-count{color:#3b82f6}
612
- .service-search{display:flex;align-items:center;gap:10px;margin:-.35rem 0 1rem;background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:9px 12px}
613
- .service-search-label{font-size:.76rem;color:#64748b;font-weight:600;letter-spacing:.02em;text-transform:uppercase;white-space:nowrap}
614
- .service-search-input{flex:1;min-width:180px;background:#0a0f1a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.88rem;padding:8px 11px;outline:none;transition:border-color .15s}
615
- .service-search-input:focus{border-color:#3b82f6}
616
- .service-search-count{font-size:.78rem;color:#64748b;white-space:nowrap}
617
- .project-edit{display:none;margin-top:8px;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px}
642
+ .project-tab .tab-count{font-size:.7rem;color:#475569;margin-left:4px;font-weight:400}
643
+ .project-tab.active .tab-count{color:#3b82f6}
644
+ .service-search{display:flex;align-items:center;gap:10px;margin:-.35rem 0 1rem;background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:9px 12px}
645
+ .service-search-label{font-size:.76rem;color:#64748b;font-weight:600;letter-spacing:.02em;text-transform:uppercase;white-space:nowrap}
646
+ .service-search-input{flex:1;min-width:180px;background:#0a0f1a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.88rem;padding:8px 11px;outline:none;transition:border-color .15s}
647
+ .service-search-input:focus{border-color:#3b82f6}
648
+ .service-search-count{font-size:.78rem;color:#64748b;white-space:nowrap}
649
+ .project-edit{display:none;margin-top:8px;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px}
618
650
  .project-edit.open{display:flex;gap:6px;align-items:center}
619
651
  .project-edit input{background:#1e293b;border:1px solid #334155;border-radius:4px;color:#e2e8f0;font-size:.78rem;padding:4px 8px;outline:none;flex:1;font-family:'Courier New',monospace;transition:border-color .15s}
620
652
  .project-edit input:focus{border-color:#3b82f6}
@@ -868,13 +900,13 @@ function dashboardHtml(port, whitelist, isStaged = false) {
868
900
  <div class="wizard-foot" id="wizard-foot"></div>
869
901
  </div>
870
902
 
871
- <div id="project-tabs" class="project-tabs" style="display:none"></div>
872
- <div id="service-search" class="service-search">
873
- <span class="service-search-label">Search</span>
874
- <input id="service-search-input" class="service-search-input" type="search" placeholder="service name or display name" autocomplete="off" spellcheck="false" oninput="setServiceSearch(this.value)">
875
- <span id="service-search-count" class="service-search-count"></span>
876
- </div>
877
- <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
903
+ <div id="project-tabs" class="project-tabs" style="display:none"></div>
904
+ <div id="service-search" class="service-search">
905
+ <span class="service-search-label">Search</span>
906
+ <input id="service-search-input" class="service-search-input" type="search" placeholder="service name or display name" autocomplete="off" spellcheck="false" oninput="setServiceSearch(this.value)">
907
+ <span id="service-search-count" class="service-search-count"></span>
908
+ </div>
909
+ <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
878
910
  <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
879
911
  </div>
880
912
 
@@ -968,6 +1000,13 @@ function renderSetPanel(name) {
968
1000
  }
969
1001
 
970
1002
  // ── Boot: check lock state ──────────────────
1003
+ let writeToken = null;
1004
+
1005
+ function writeHeaders(extra) {
1006
+ if (!writeToken) throw new Error("Write access requires password unlock in this browser session.");
1007
+ return { ...(extra || {}), "X-Clauth-Write-Token": writeToken };
1008
+ }
1009
+
971
1010
  async function boot() {
972
1011
  try {
973
1012
  const ping = await fetch(BASE + "/ping").then(r => r.json());
@@ -1090,6 +1129,7 @@ async function unlock() {
1090
1129
  throw new Error(r.error + rem);
1091
1130
  }
1092
1131
 
1132
+ writeToken = r.write_token || null;
1093
1133
  input.value = "";
1094
1134
  const ping = await fetch(BASE + "/ping").then(r => r.json());
1095
1135
  showMain(ping);
@@ -1107,6 +1147,7 @@ async function unlock() {
1107
1147
  // ── Lock ────────────────────────────────────
1108
1148
  async function lockVault() {
1109
1149
  const r = await fetch(BASE + "/lock", { method: "POST" }).then(r => r.json()).catch(() => ({}));
1150
+ writeToken = null;
1110
1151
  showLockScreen(r.hard_locked || false);
1111
1152
  }
1112
1153
 
@@ -1168,25 +1209,25 @@ async function stopDaemon() {
1168
1209
  }
1169
1210
 
1170
1211
  // ── Load services ───────────────────────────
1171
- let allServices = [];
1172
- let activeProjectTab = "all";
1173
- let serviceSearchQuery = "";
1174
-
1175
- function serviceSort(a, b) {
1176
- return String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base", numeric: true });
1177
- }
1178
-
1179
- function matchesServiceSearch(s, query) {
1180
- if (!query) return true;
1181
- const q = query.toLowerCase();
1182
- return String(s.name || "").toLowerCase().includes(q) ||
1183
- String(s.label || "").toLowerCase().includes(q);
1184
- }
1185
-
1186
- function setServiceSearch(value) {
1187
- serviceSearchQuery = value || "";
1188
- renderServiceGrid(allServices);
1189
- }
1212
+ let allServices = [];
1213
+ let activeProjectTab = "all";
1214
+ let serviceSearchQuery = "";
1215
+
1216
+ function serviceSort(a, b) {
1217
+ return String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base", numeric: true });
1218
+ }
1219
+
1220
+ function matchesServiceSearch(s, query) {
1221
+ if (!query) return true;
1222
+ const q = query.toLowerCase();
1223
+ return String(s.name || "").toLowerCase().includes(q) ||
1224
+ String(s.label || "").toLowerCase().includes(q);
1225
+ }
1226
+
1227
+ function setServiceSearch(value) {
1228
+ serviceSearchQuery = value || "";
1229
+ renderServiceGrid(allServices);
1230
+ }
1190
1231
 
1191
1232
  function renderProjectTabs(services) {
1192
1233
  const tabsEl = document.getElementById("project-tabs");
@@ -1212,35 +1253,35 @@ function renderProjectTabs(services) {
1212
1253
  ).join("");
1213
1254
  }
1214
1255
 
1215
- function switchProjectTab(key) {
1216
- activeProjectTab = key;
1217
- renderProjectTabs(allServices);
1218
- renderServiceGrid(allServices);
1219
- }
1256
+ function switchProjectTab(key) {
1257
+ activeProjectTab = key;
1258
+ renderProjectTabs(allServices);
1259
+ renderServiceGrid(allServices);
1260
+ }
1220
1261
 
1221
1262
  function renderServiceGrid(services) {
1222
1263
  const grid = document.getElementById("grid");
1223
1264
  let filtered = services;
1224
1265
  if (activeProjectTab === "unassigned") {
1225
1266
  filtered = services.filter(s => !s.project);
1226
- } else if (activeProjectTab !== "all") {
1227
- filtered = services.filter(s => s.project === activeProjectTab);
1228
- }
1229
- filtered = filtered
1230
- .filter(s => matchesServiceSearch(s, serviceSearchQuery))
1231
- .slice()
1232
- .sort(serviceSort);
1233
- const searchCount = document.getElementById("service-search-count");
1234
- if (searchCount) {
1235
- const trimmed = serviceSearchQuery.trim();
1236
- searchCount.textContent = trimmed ? filtered.length + " match" + (filtered.length === 1 ? "" : "es") : filtered.length + " shown";
1237
- }
1238
- if (!filtered.length) {
1239
- grid.innerHTML = serviceSearchQuery.trim()
1240
- ? '<p class="loading">No services match that search.</p>'
1241
- : '<p class="loading">No services in this group.</p>';
1242
- return;
1243
- }
1267
+ } else if (activeProjectTab !== "all") {
1268
+ filtered = services.filter(s => s.project === activeProjectTab);
1269
+ }
1270
+ filtered = filtered
1271
+ .filter(s => matchesServiceSearch(s, serviceSearchQuery))
1272
+ .slice()
1273
+ .sort(serviceSort);
1274
+ const searchCount = document.getElementById("service-search-count");
1275
+ if (searchCount) {
1276
+ const trimmed = serviceSearchQuery.trim();
1277
+ searchCount.textContent = trimmed ? filtered.length + " match" + (filtered.length === 1 ? "" : "es") : filtered.length + " shown";
1278
+ }
1279
+ if (!filtered.length) {
1280
+ grid.innerHTML = serviceSearchQuery.trim()
1281
+ ? '<p class="loading">No services match that search.</p>'
1282
+ : '<p class="loading">No services in this group.</p>';
1283
+ return;
1284
+ }
1244
1285
  grid.innerHTML = filtered.map(s => \`
1245
1286
  <div class="card">
1246
1287
  <div style="display:flex;align-items:flex-start;justify-content:space-between">
@@ -1398,7 +1439,7 @@ async function rotateKey(name) {
1398
1439
  const rotBtn = document.getElementById("rotbtn-" + name);
1399
1440
  if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
1400
1441
  try {
1401
- const r = await fetch(BASE + "/rotate/" + name, { method: "POST" }).then(r => r.json());
1442
+ const r = await fetch(BASE + "/rotate/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
1402
1443
  if (r.ok) {
1403
1444
  if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
1404
1445
  loadExpiry(); // refresh badges
@@ -1419,7 +1460,7 @@ async function setExpiry(name) {
1419
1460
  try {
1420
1461
  await fetch(BASE + "/set-expiry/" + name, {
1421
1462
  method: "POST",
1422
- headers: { "Content-Type": "application/json" },
1463
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1423
1464
  body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
1424
1465
  });
1425
1466
  loadExpiry();
@@ -1487,7 +1528,7 @@ async function saveProject(name) {
1487
1528
  try {
1488
1529
  const r = await fetch(BASE + "/update-service", {
1489
1530
  method: "POST",
1490
- headers: { "Content-Type": "application/json" },
1531
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1491
1532
  body: JSON.stringify({ service: name, project: project || "" })
1492
1533
  }).then(r => r.json());
1493
1534
  if (r.locked) { showLockScreen(); return; }
@@ -1532,7 +1573,7 @@ async function saveLabel(name) {
1532
1573
  try {
1533
1574
  const r = await fetch(BASE + "/update-service", {
1534
1575
  method: "POST",
1535
- headers: { "Content-Type": "application/json" },
1576
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1536
1577
  body: JSON.stringify({ service: name, label: newLabel })
1537
1578
  }).then(r => r.json());
1538
1579
  if (r.locked) { showLockScreen(false); return; }
@@ -1556,7 +1597,7 @@ async function saveRename(oldName) { await saveLabel(oldName); }
1556
1597
  async function deleteService(name) {
1557
1598
  if (!confirm(\`Delete service "\${name}"? This cannot be undone.\`)) return;
1558
1599
  try {
1559
- const r = await fetch(BASE + "/delete/" + name, { method: "POST" }).then(r => r.json());
1600
+ const r = await fetch(BASE + "/delete/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
1560
1601
  if (r.locked) { showLockScreen(false); return; }
1561
1602
  if (r.error) throw new Error(r.error);
1562
1603
  loadServices();
@@ -1612,15 +1653,15 @@ async function saveKey(name) {
1612
1653
  value = JSON.stringify(obj);
1613
1654
  } else {
1614
1655
  const input = document.getElementById("set-input-" + name);
1615
- value = input ? input.value.trim() : "";
1616
- if (!value) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
1656
+ value = input ? input.value : "";
1657
+ if (!value.trim()) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
1617
1658
  }
1618
1659
 
1619
1660
  msg.className = "set-msg"; msg.textContent = "Saving…";
1620
1661
  try {
1621
1662
  const r = await fetch(BASE + "/set/" + name, {
1622
1663
  method: "POST",
1623
- headers: { "Content-Type": "application/json" },
1664
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1624
1665
  body: JSON.stringify({ value })
1625
1666
  }).then(r => r.json());
1626
1667
 
@@ -1658,7 +1699,7 @@ async function toggleService(name) {
1658
1699
  try {
1659
1700
  const r = await fetch(BASE + "/toggle/" + name, {
1660
1701
  method: "POST",
1661
- headers: { "Content-Type": "application/json" },
1702
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1662
1703
  body: JSON.stringify({ enabled: newState })
1663
1704
  }).then(r => r.json());
1664
1705
 
@@ -1763,12 +1804,13 @@ async function changePassword() {
1763
1804
  try {
1764
1805
  const r = await fetch(BASE + "/change-pw", {
1765
1806
  method: "POST",
1766
- headers: { "Content-Type": "application/json" },
1807
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1767
1808
  body: JSON.stringify({ newPassword: newPw })
1768
1809
  }).then(r => r.json());
1769
1810
 
1770
1811
  if (r.locked) { showLockScreen(); return; }
1771
1812
  if (r.error) throw new Error(r.error);
1813
+ writeToken = r.write_token || null;
1772
1814
 
1773
1815
  msg.className = "chpw-msg ok"; msg.textContent = "✓ Password updated";
1774
1816
  document.getElementById("chpw-new").value = "";
@@ -1838,7 +1880,7 @@ async function addService() {
1838
1880
  if (project) payload.project = project;
1839
1881
  const r = await fetch(BASE + "/add-service", {
1840
1882
  method: "POST",
1841
- headers: { "Content-Type": "application/json" },
1883
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1842
1884
  body: JSON.stringify(payload)
1843
1885
  }).then(r => r.json());
1844
1886
 
@@ -2058,7 +2100,7 @@ async function wizSubmitCfToken() {
2058
2100
 
2059
2101
  const r = await fetch(BASE + "/tunnel/setup/cf-token", {
2060
2102
  method: "POST",
2061
- headers: { "Content-Type": "application/json" },
2103
+ headers: writeHeaders({ "Content-Type": "application/json" }),
2062
2104
  body: JSON.stringify({ token }),
2063
2105
  }).then(r => r.json()).catch(() => null);
2064
2106
 
@@ -2496,6 +2538,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2496
2538
  const UNKNOWN_SERVICE_THRESHOLD = 2; // misses before we return the full service list
2497
2539
  let authHardLocked = false;
2498
2540
  let password = initPassword || null; // null = locked; set via POST /auth
2541
+ let writeSession = null; // null until explicit password auth grants write scope
2499
2542
  const machineHash = getMachineHash();
2500
2543
 
2501
2544
  // Rotation engine — starts after unlock
@@ -2522,6 +2565,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2522
2565
  whitelist,
2523
2566
  failCount,
2524
2567
  MAX_FAILS,
2568
+ writeEnabled: process.env.CLAUTH_MCP_WRITE === "1",
2525
2569
  };
2526
2570
  }
2527
2571
 
@@ -2916,6 +2960,22 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2916
2960
  const studioDebugHandled = await studioDebugRuntime.handle(req, res, url, CORS);
2917
2961
  if (studioDebugHandled !== false) return;
2918
2962
 
2963
+ if (method === "GET" && reqPath === "/watchdog/services") {
2964
+ return ok(res, await getWatchdogStatuses());
2965
+ }
2966
+
2967
+ if (method === "GET" && reqPath === "/watchdog/events") {
2968
+ const limit = Number(url.searchParams.get("limit") || 100);
2969
+ return ok(res, { events: readWatchdogEvents(limit) });
2970
+ }
2971
+
2972
+ const restartMatch = reqPath.match(/^\/watchdog\/services\/([^/]+)\/restart$/);
2973
+ if (method === "POST" && restartMatch) {
2974
+ const result = restartWatchdogService(decodeURIComponent(restartMatch[1]));
2975
+ res.writeHead(result.ok ? 200 : 403, { "Content-Type": "application/json", ...CORS });
2976
+ return res.end(JSON.stringify(result));
2977
+ }
2978
+
2919
2979
  // ── Hosts that bypass OAuth (fresh domains for claude.ai compatibility) ──
2920
2980
  const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
2921
2981
  const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
@@ -3115,12 +3175,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3115
3175
  const MCP_PATHS = ["/mcp", "/gws", "/clauth", "/fs", "/chitchat", "/codevelop"];
3116
3176
  const isMcpPath = MCP_PATHS.includes(reqPath);
3117
3177
  function toolsForPath(p) {
3118
- if (p === "/gws") return MCP_TOOLS.filter(t => t.name.startsWith("gws_"));
3119
- if (p === "/clauth") return MCP_TOOLS.filter(t => t.name.startsWith("clauth_") || t.name === "monkey_dispatch" || t.name.startsWith("terminal_") || t.name.startsWith("channel_"));
3120
- if (p === "/fs") return MCP_TOOLS.filter(t => t.name.startsWith("fs_"));
3121
- if (p === "/chitchat") return MCP_TOOLS.filter(t => t.name.startsWith("chitchat_"));
3122
- if (p === "/codevelop") return MCP_TOOLS.filter(t => t.name.startsWith("codevelop_"));
3123
- return MCP_TOOLS; // /mcp all tools
3178
+ const tools = filterMcpToolsForWriteMode(MCP_TOOLS);
3179
+ if (p === "/gws") return tools.filter(t => t.name.startsWith("gws_"));
3180
+ if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "monkey_dispatch" || t.name.startsWith("terminal_") || t.name.startsWith("channel_"));
3181
+ if (p === "/fs") return tools.filter(t => t.name.startsWith("fs_"));
3182
+ if (p === "/chitchat") return tools.filter(t => t.name.startsWith("chitchat_"));
3183
+ if (p === "/codevelop") return tools.filter(t => t.name.startsWith("codevelop_"));
3184
+ return tools; // /mcp — all tools
3124
3185
  }
3125
3186
  function serverNameForPath(p) {
3126
3187
  if (p === "/gws") return "gws";
@@ -3324,7 +3385,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3324
3385
  if (rpcMethod === "tools/list") {
3325
3386
  sseSend(session.res, "message", {
3326
3387
  jsonrpc: "2.0", id,
3327
- result: { tools: MCP_TOOLS }
3388
+ result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
3328
3389
  });
3329
3390
  return;
3330
3391
  }
@@ -4121,6 +4182,16 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4121
4182
  return false;
4122
4183
  }
4123
4184
 
4185
+ function writeGuard(req, res) {
4186
+ if (lockedGuard(res)) return true;
4187
+ if (!validateWriteToken(req, writeSession)) {
4188
+ res.writeHead(403, { "Content-Type": "application/json", ...CORS });
4189
+ res.end(JSON.stringify({ error: "write token required", write_locked: true }));
4190
+ return true;
4191
+ }
4192
+ return false;
4193
+ }
4194
+
4124
4195
  // GET /meta — return valid key_types from DB check constraint
4125
4196
  if (method === "GET" && reqPath === "/meta") {
4126
4197
  if (lockedGuard(res)) return;
@@ -4311,6 +4382,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4311
4382
  const result = await api.test(pw, machineHash, token, timestamp);
4312
4383
  if (result.error) throw new Error(result.error);
4313
4384
  password = pw; // unlock — store in process memory only
4385
+ writeSession = makeWriteToken();
4314
4386
  authFailCount = 0;
4315
4387
  authHardLocked = false;
4316
4388
  const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
@@ -4365,7 +4437,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4365
4437
  tunnelStatus = "starting";
4366
4438
  startTunnel().catch(() => {});
4367
4439
  }
4368
- return ok(res, { ok: true, locked: false });
4440
+ return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
4369
4441
  } catch {
4370
4442
  authFailCount++;
4371
4443
  const authRemaining = MAX_AUTH_FAILS - authFailCount;
@@ -4386,6 +4458,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4386
4458
  // POST /lock — clear password from memory
4387
4459
  if (method === "POST" && reqPath === "/lock") {
4388
4460
  password = null;
4461
+ writeSession = null;
4389
4462
  stopTunnel();
4390
4463
  const logLine = `[${new Date().toISOString()}] Vault locked\n`;
4391
4464
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
@@ -4395,7 +4468,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4395
4468
  // POST /rename/:service — rename a service
4396
4469
  const renameMatch = reqPath.match(/^\/rename\/([a-zA-Z0-9_-]+)$/);
4397
4470
  if (method === "POST" && renameMatch) {
4398
- if (lockedGuard(res)) return;
4471
+ if (writeGuard(req, res)) return;
4399
4472
  const service = renameMatch[1].toLowerCase();
4400
4473
  let body;
4401
4474
  try { body = await readBody(req); } catch {
@@ -4420,7 +4493,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4420
4493
  // POST /delete/:service — remove a service entirely
4421
4494
  const deleteMatch = reqPath.match(/^\/delete\/([a-zA-Z0-9_-]+)$/);
4422
4495
  if (method === "POST" && deleteMatch) {
4423
- if (lockedGuard(res)) return;
4496
+ if (writeGuard(req, res)) return;
4424
4497
  const service = deleteMatch[1].toLowerCase();
4425
4498
  try {
4426
4499
  const { token, timestamp } = deriveToken(password, machineHash);
@@ -4435,7 +4508,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4435
4508
  // POST /toggle/:service — enable or disable a service
4436
4509
  const toggleMatch = reqPath.match(/^\/toggle\/([a-zA-Z0-9_-]+)$/);
4437
4510
  if (method === "POST" && toggleMatch) {
4438
- if (lockedGuard(res)) return;
4511
+ if (writeGuard(req, res)) return;
4439
4512
  const service = toggleMatch[1].toLowerCase();
4440
4513
 
4441
4514
  let body;
@@ -4519,7 +4592,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4519
4592
 
4520
4593
  // POST /rotate/:service — manually trigger rotation for a service
4521
4594
  if (method === "POST" && reqPath.startsWith("/rotate/")) {
4522
- if (lockedGuard(res)) return;
4595
+ if (writeGuard(req, res)) return;
4523
4596
  const service = reqPath.slice("/rotate/".length);
4524
4597
  if (!service) {
4525
4598
  res.writeHead(400, { "Content-Type": "application/json", ...CORS });
@@ -4531,7 +4604,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4531
4604
 
4532
4605
  // POST /set-expiry/:service — set expiry date for a service
4533
4606
  if (method === "POST" && reqPath.startsWith("/set-expiry/")) {
4534
- if (lockedGuard(res)) return;
4607
+ if (writeGuard(req, res)) return;
4535
4608
  const service = reqPath.slice("/set-expiry/".length);
4536
4609
  let body;
4537
4610
  try { body = await readBody(req); } catch {
@@ -4688,7 +4761,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4688
4761
 
4689
4762
  // POST /tunnel/setup/cf-token
4690
4763
  if (method === "POST" && reqPath === "/tunnel/setup/cf-token") {
4691
- if (lockedGuard(res)) return;
4764
+ if (writeGuard(req, res)) return;
4692
4765
  let body;
4693
4766
  try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
4694
4767
  const { token: cfToken } = body;
@@ -4708,9 +4781,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4708
4781
  const accountId = ad?.result?.[0]?.id;
4709
4782
  const accountName = ad?.result?.[0]?.name;
4710
4783
 
4711
- // Save token to vault using api.write (same as /set/:service)
4712
- const { token: t, timestamp } = deriveToken(password, machineHash);
4713
- await api.write(password, machineHash, t, timestamp, "cloudflare", cfToken);
4784
+ // Save token to vault using the same guarded recovery path as /set/:service.
4785
+ await writeCredentialWithRecovery({ password, machineHash, service: "cloudflare", value: cfToken, logFile: LOG_FILE });
4714
4786
 
4715
4787
  // Save accountId to clauth_config
4716
4788
  const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
@@ -4991,7 +5063,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4991
5063
 
4992
5064
  // POST /change-pw — change master password (must be unlocked)
4993
5065
  if (method === "POST" && reqPath === "/change-pw") {
4994
- if (lockedGuard(res)) return;
5066
+ if (writeGuard(req, res)) return;
4995
5067
 
4996
5068
  let body;
4997
5069
  try { body = await readBody(req); } catch {
@@ -5011,9 +5083,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5011
5083
  const result = await api.changePassword(password, machineHash, token, timestamp, newSeedHash);
5012
5084
  if (result.error) throw new Error(result.error);
5013
5085
  password = newPassword; // update in-memory password to new one
5086
+ writeSession = makeWriteToken();
5014
5087
  const logLine = `[${new Date().toISOString()}] Password changed\n`;
5015
5088
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
5016
- return ok(res, { ok: true });
5089
+ return ok(res, { ok: true, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
5017
5090
  } catch (err) {
5018
5091
  res.writeHead(502, { "Content-Type": "application/json", ...CORS });
5019
5092
  return res.end(JSON.stringify({ error: err.message }));
@@ -5023,7 +5096,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5023
5096
  // POST /set/:service — write a new key value into vault
5024
5097
  const setMatch = reqPath.match(/^\/set\/([a-zA-Z0-9_-]+)$/);
5025
5098
  if (method === "POST" && setMatch) {
5026
- if (lockedGuard(res)) return;
5099
+ if (writeGuard(req, res)) return;
5027
5100
  const service = setMatch[1].toLowerCase();
5028
5101
 
5029
5102
  if (whitelist && !whitelist.includes(service)) {
@@ -5042,10 +5115,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5042
5115
  }
5043
5116
 
5044
5117
  try {
5045
- const { token, timestamp } = deriveToken(password, machineHash);
5046
- const result = await api.write(password, machineHash, token, timestamp, service, value.trim());
5118
+ const { result, snapshot, normalized } = await writeCredentialWithRecovery({ password, machineHash, service, value, logFile: LOG_FILE });
5047
5119
  if (result.error) return strike(res, 502, result.error);
5048
- return ok(res, { ok: true, service });
5120
+ return ok(res, { ok: true, service, recovery_snapshot: snapshot?.ok ? true : false, normalized });
5049
5121
  } catch (err) {
5050
5122
  return strike(res, 502, err.message);
5051
5123
  }
@@ -5053,7 +5125,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5053
5125
 
5054
5126
  // POST /generate-token — generate a cryptographically random bearer token and store it
5055
5127
  if (method === "POST" && reqPath === "/generate-token") {
5056
- if (lockedGuard(res)) return;
5128
+ if (writeGuard(req, res)) return;
5057
5129
 
5058
5130
  let body;
5059
5131
  try { body = await readBody(req); } catch {
@@ -5075,10 +5147,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5075
5147
  try {
5076
5148
  const randomHex = crypto.randomBytes(32).toString("hex");
5077
5149
  const token = `${prefix}${randomHex}`;
5078
- const { token: authToken, timestamp } = deriveToken(password, machineHash);
5079
- const result = await api.write(password, machineHash, authToken, timestamp, service, token);
5150
+ const { result, snapshot } = await writeCredentialWithRecovery({ password, machineHash, service, value: token, logFile: LOG_FILE, normalize: false });
5080
5151
  if (result.error) return strike(res, 502, result.error);
5081
- return ok(res, { token, service, stored: true });
5152
+ return ok(res, { token, service, stored: true, recovery_snapshot: snapshot?.ok ? true : false });
5082
5153
  } catch (err) {
5083
5154
  return strike(res, 502, err.message);
5084
5155
  }
@@ -5086,7 +5157,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5086
5157
 
5087
5158
  // POST /add-service — register a new service in the vault
5088
5159
  if (method === "POST" && reqPath === "/add-service") {
5089
- if (lockedGuard(res)) return;
5160
+ if (writeGuard(req, res)) return;
5090
5161
 
5091
5162
  let body;
5092
5163
  try { body = await readBody(req); } catch {
@@ -5117,8 +5188,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5117
5188
  }
5118
5189
 
5119
5190
  // POST /update-service — update service metadata (project, label, description)
5120
- if (method === "POST" && reqPath === "/update-service") {
5121
- if (lockedGuard(res)) return;
5191
+ if (method === "POST" && reqPath === "/update-service") {
5192
+ if (writeGuard(req, res)) return;
5122
5193
 
5123
5194
  let body;
5124
5195
  try { body = await readBody(req); } catch {
@@ -5149,39 +5220,39 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5149
5220
  return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
5150
5221
  } catch (err) {
5151
5222
  return strike(res, 502, err.message);
5152
- }
5153
- }
5154
-
5155
- if (method === "GET" && reqPath === "/search") {
5156
- if (lockedGuard(res)) return;
5157
- const query = (url.searchParams.get("q") || url.searchParams.get("query") || "").trim();
5158
- const project = (url.searchParams.get("project") || "").trim() || undefined;
5159
- const includeAddresses = url.searchParams.get("addresses") !== "false";
5160
- if (!query) {
5161
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5162
- return res.end(JSON.stringify({ error: "q query parameter is required" }));
5163
- }
5164
-
5165
- try {
5166
- const { token, timestamp } = deriveToken(password, machineHash);
5167
- const result = await searchServices({
5168
- password,
5169
- machineHash,
5170
- token,
5171
- timestamp,
5172
- project,
5173
- query,
5174
- includeAddresses,
5175
- whitelist
5176
- });
5177
- if (result.error) return strike(res, 502, result.error);
5178
- return ok(res, result);
5179
- } catch (err) {
5180
- return strike(res, 502, err.message);
5181
- }
5182
- }
5183
-
5184
- // Unknown route — a wrong URL is not an auth failure. Log it, return 404,
5223
+ }
5224
+ }
5225
+
5226
+ if (method === "GET" && reqPath === "/search") {
5227
+ if (lockedGuard(res)) return;
5228
+ const query = (url.searchParams.get("q") || url.searchParams.get("query") || "").trim();
5229
+ const project = (url.searchParams.get("project") || "").trim() || undefined;
5230
+ const includeAddresses = url.searchParams.get("addresses") !== "false";
5231
+ if (!query) {
5232
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5233
+ return res.end(JSON.stringify({ error: "q query parameter is required" }));
5234
+ }
5235
+
5236
+ try {
5237
+ const { token, timestamp } = deriveToken(password, machineHash);
5238
+ const result = await searchServices({
5239
+ password,
5240
+ machineHash,
5241
+ token,
5242
+ timestamp,
5243
+ project,
5244
+ query,
5245
+ includeAddresses,
5246
+ whitelist
5247
+ });
5248
+ if (result.error) return strike(res, 502, result.error);
5249
+ return ok(res, result);
5250
+ } catch (err) {
5251
+ return strike(res, 502, err.message);
5252
+ }
5253
+ }
5254
+
5255
+ // Unknown route — a wrong URL is not an auth failure. Log it, return 404,
5185
5256
  // but do NOT increment failCount (which locks the vault at MAX_FAILS).
5186
5257
  // Auth failures (wrong password, wrong token) still strike via /auth and /get/:service.
5187
5258
  try {
@@ -5552,8 +5623,8 @@ async function actionForeground(opts) {
5552
5623
  // Reuses the same auth model as the HTTP daemon.
5553
5624
  // Secrets are delivered via temp files — never in the MCP response.
5554
5625
 
5555
- import { createInterface } from "readline";
5556
- import { execSync, spawn as spawnProc, spawnSync } from "child_process";
5626
+ import { createInterface } from "readline";
5627
+ import { execSync, spawn as spawnProc, spawnSync } from "child_process";
5557
5628
 
5558
5629
  // ── Monkey dispatch — headless Claude CLI worker ─────────────────
5559
5630
  function findClaudeBinary() {
@@ -6156,9 +6227,9 @@ function stopCodevelopSession(session_id) {
6156
6227
  return { stopped: true, session_id };
6157
6228
  }
6158
6229
 
6159
- const ENV_MAP = {
6160
- "github": "GITHUB_TOKEN",
6161
- "supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
6230
+ const ENV_MAP = {
6231
+ "github": "GITHUB_TOKEN",
6232
+ "supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
6162
6233
  "supabase-service": "SUPABASE_SERVICE_ROLE_KEY",
6163
6234
  "supabase-db": "SUPABASE_DB_URL",
6164
6235
  "vercel": "VERCEL_TOKEN",
@@ -6170,116 +6241,116 @@ const ENV_MAP = {
6170
6241
  "rocketreach": "ROCKETREACH_API_KEY",
6171
6242
  "npm": "NPM_TOKEN",
6172
6243
  "namecheap": "NAMECHEAP_API_KEY",
6173
- "gmail": "GMAIL_CREDENTIALS",
6174
- };
6175
-
6176
- const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
6177
- const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
6178
-
6179
- function normalizeSearchText(value) {
6180
- return String(value || "").toLowerCase();
6181
- }
6182
-
6183
- function redactUrlish(value) {
6184
- const text = String(value || "").trim();
6185
- if (!text) return "";
6186
- try {
6187
- const url = new URL(text);
6188
- if (url.username) url.username = "***";
6189
- if (url.password) url.password = "***";
6190
- return url.toString();
6191
- } catch {
6192
- return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
6193
- }
6194
- }
6195
-
6196
- function collectAddressHints(value, keyType) {
6197
- if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
6198
- const hints = new Set();
6199
-
6200
- function add(candidate) {
6201
- if (candidate === undefined || candidate === null) return;
6202
- const text = redactUrlish(candidate);
6203
- if (text) hints.add(text);
6204
- }
6205
-
6206
- function walk(node, fieldName = "") {
6207
- if (node === undefined || node === null) return;
6208
- if (typeof node === "string") {
6209
- if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
6210
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
6211
- return;
6212
- }
6213
- if (Array.isArray(node)) {
6214
- for (const item of node) walk(item, fieldName);
6215
- return;
6216
- }
6217
- if (typeof node === "object") {
6218
- for (const [key, child] of Object.entries(node)) walk(child, key);
6219
- }
6220
- }
6221
-
6222
- try {
6223
- walk(JSON.parse(value));
6224
- } catch {
6225
- walk(value);
6226
- }
6227
-
6228
- return [...hints];
6229
- }
6230
-
6231
- async function searchServices({ password, machineHash, token, timestamp, query, project, includeAddresses = true, whitelist }) {
6232
- const q = normalizeSearchText(query);
6233
- if (!q) return { error: "query is required" };
6234
-
6235
- const result = await api.status(password, machineHash, token, timestamp, project);
6236
- if (result.error) return { error: result.error };
6237
-
6238
- let services = result.services || [];
6239
- if (whitelist) services = services.filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
6240
-
6241
- const matches = [];
6242
- for (const s of services) {
6243
- const fields = {
6244
- name: s.name,
6245
- label: s.label,
6246
- project: s.project,
6247
- type: s.key_type,
6248
- description: s.description
6249
- };
6250
- const matched = Object.entries(fields)
6251
- .filter(([, value]) => normalizeSearchText(value).includes(q))
6252
- .map(([field]) => field);
6253
-
6254
- let addressHints = [];
6255
- if (includeAddresses && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && s.vault_key) {
6256
- const secret = await api.retrieve(password, machineHash, token, timestamp, s.name);
6257
- if (!secret.error) {
6258
- addressHints = collectAddressHints(secret.value, s.key_type);
6259
- if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
6260
- }
6261
- }
6262
-
6263
- if (matched.length) {
6264
- matches.push({
6265
- name: s.name,
6266
- label: s.label || null,
6267
- project: s.project || null,
6268
- key_type: s.key_type,
6269
- enabled: !!s.enabled,
6270
- has_key: !!s.vault_key,
6271
- description: s.description || null,
6272
- matched: [...new Set(matched)],
6273
- address_hints: addressHints
6274
- });
6275
- }
6276
- }
6277
-
6278
- return { query, count: matches.length, matches };
6279
- }
6280
-
6281
- // ── Filesystem service config — loaded from clauth vault ──
6282
- let _fsMountsCache = null;
6244
+ "gmail": "GMAIL_CREDENTIALS",
6245
+ };
6246
+
6247
+ const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
6248
+ const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
6249
+
6250
+ function normalizeSearchText(value) {
6251
+ return String(value || "").toLowerCase();
6252
+ }
6253
+
6254
+ function redactUrlish(value) {
6255
+ const text = String(value || "").trim();
6256
+ if (!text) return "";
6257
+ try {
6258
+ const url = new URL(text);
6259
+ if (url.username) url.username = "***";
6260
+ if (url.password) url.password = "***";
6261
+ return url.toString();
6262
+ } catch {
6263
+ return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
6264
+ }
6265
+ }
6266
+
6267
+ function collectAddressHints(value, keyType) {
6268
+ if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
6269
+ const hints = new Set();
6270
+
6271
+ function add(candidate) {
6272
+ if (candidate === undefined || candidate === null) return;
6273
+ const text = redactUrlish(candidate);
6274
+ if (text) hints.add(text);
6275
+ }
6276
+
6277
+ function walk(node, fieldName = "") {
6278
+ if (node === undefined || node === null) return;
6279
+ if (typeof node === "string") {
6280
+ if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
6281
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
6282
+ return;
6283
+ }
6284
+ if (Array.isArray(node)) {
6285
+ for (const item of node) walk(item, fieldName);
6286
+ return;
6287
+ }
6288
+ if (typeof node === "object") {
6289
+ for (const [key, child] of Object.entries(node)) walk(child, key);
6290
+ }
6291
+ }
6292
+
6293
+ try {
6294
+ walk(JSON.parse(value));
6295
+ } catch {
6296
+ walk(value);
6297
+ }
6298
+
6299
+ return [...hints];
6300
+ }
6301
+
6302
+ async function searchServices({ password, machineHash, token, timestamp, query, project, includeAddresses = true, whitelist }) {
6303
+ const q = normalizeSearchText(query);
6304
+ if (!q) return { error: "query is required" };
6305
+
6306
+ const result = await api.status(password, machineHash, token, timestamp, project);
6307
+ if (result.error) return { error: result.error };
6308
+
6309
+ let services = result.services || [];
6310
+ if (whitelist) services = services.filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
6311
+
6312
+ const matches = [];
6313
+ for (const s of services) {
6314
+ const fields = {
6315
+ name: s.name,
6316
+ label: s.label,
6317
+ project: s.project,
6318
+ type: s.key_type,
6319
+ description: s.description
6320
+ };
6321
+ const matched = Object.entries(fields)
6322
+ .filter(([, value]) => normalizeSearchText(value).includes(q))
6323
+ .map(([field]) => field);
6324
+
6325
+ let addressHints = [];
6326
+ if (includeAddresses && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && s.vault_key) {
6327
+ const secret = await api.retrieve(password, machineHash, token, timestamp, s.name);
6328
+ if (!secret.error) {
6329
+ addressHints = collectAddressHints(secret.value, s.key_type);
6330
+ if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
6331
+ }
6332
+ }
6333
+
6334
+ if (matched.length) {
6335
+ matches.push({
6336
+ name: s.name,
6337
+ label: s.label || null,
6338
+ project: s.project || null,
6339
+ key_type: s.key_type,
6340
+ enabled: !!s.enabled,
6341
+ has_key: !!s.vault_key,
6342
+ description: s.description || null,
6343
+ matched: [...new Set(matched)],
6344
+ address_hints: addressHints
6345
+ });
6346
+ }
6347
+ }
6348
+
6349
+ return { query, count: matches.length, matches };
6350
+ }
6351
+
6352
+ // ── Filesystem service config — loaded from clauth vault ──
6353
+ let _fsMountsCache = null;
6283
6354
  let _fsMountsCacheTime = 0;
6284
6355
  const FS_CACHE_TTL = 60000; // 1 minute
6285
6356
 
@@ -6313,115 +6384,124 @@ async function getFileserverMounts(vault) {
6313
6384
  }
6314
6385
  }
6315
6386
 
6316
- async function resolveInMount(requestedPath, mountName, vault) {
6387
+ async function resolveInMount(requestedPath, mountName, vault) {
6317
6388
  const { mounts, error } = await getFileserverMounts(vault);
6318
6389
  if (error) return { error };
6319
- if (!mounts || mounts.length === 0) return { error: "No fileserver services configured. Add one with key_type='fileserver' and value: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwd\"}" };
6390
+ if (!mounts || mounts.length === 0) return { error: "No fileserver services configured. Add one with key_type='fileserver' and value: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwdg\"} (access flags: r=read w=write d=delete g=git)" };
6320
6391
  const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
6321
6392
  if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
6322
6393
  if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
6323
- const resolved = path.resolve(mount.path, requestedPath);
6324
- const normalized = path.normalize(resolved);
6325
- const relative = path.relative(path.normalize(mount.path), normalized);
6326
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
6327
- return { error: `Path escapes mount: ${requestedPath}` };
6328
- }
6329
- return { resolved: normalized, mount };
6330
- }
6331
-
6332
- function checkAccess(mount, flag) {
6333
- return mount.access.includes(flag);
6334
- }
6335
-
6336
- function sha256Hex(value) {
6337
- return crypto.createHash("sha256").update(value).digest("hex");
6338
- }
6339
-
6340
- async function atomicWriteText(filePath, content) {
6341
- await mkdir(path.dirname(filePath), { recursive: true });
6342
- const tempPath = path.join(
6343
- path.dirname(filePath),
6344
- `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
6345
- );
6346
- await writeFile(tempPath, content, "utf8");
6347
- await rename(tempPath, filePath);
6348
- }
6349
-
6350
- async function fileInfo(filePath, requestedPath) {
6351
- const s = await stat(filePath);
6352
- const info = {
6353
- path: requestedPath,
6354
- type: s.isDirectory() ? "dir" : "file",
6355
- size: s.size,
6356
- modified: s.mtime.toISOString(),
6357
- };
6358
- if (s.isFile()) {
6359
- const content = await readFile(filePath);
6360
- info.sha256 = sha256Hex(content);
6361
- }
6362
- return info;
6363
- }
6364
-
6365
- const FS_UPLOAD_SESSIONS = new Map();
6366
- const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
6367
- const FS_MAX_CHUNKS = 500;
6368
- const FS_MAX_CHUNK_BYTES = 128 * 1024;
6369
- const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
6370
- const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
6371
- "docs/",
6372
- ".rdc/plans/",
6373
- ".rdc/guides/",
6374
- ".claude/context/",
6375
- ".claude/rules/",
6376
- ".rdc/relay/from-claude-ai/",
6377
- ];
6378
-
6379
- function cleanupFsUploadSessions() {
6380
- const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
6381
- for (const [id, session] of FS_UPLOAD_SESSIONS) {
6382
- if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
6383
- }
6384
- }
6385
-
6386
- function runGit(cwd, args, opts = {}) {
6387
- const res = spawnSync("git", args, {
6388
- cwd,
6389
- encoding: "utf8",
6390
- windowsHide: true,
6391
- maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
6392
- });
6393
- if (res.status !== 0) {
6394
- const detail = (res.stderr || res.stdout || "").trim();
6395
- throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
6396
- }
6397
- return (res.stdout || "").trim();
6398
- }
6399
-
6400
- function runGitRaw(cwd, args, opts = {}) {
6401
- const res = spawnSync("git", args, {
6402
- cwd,
6403
- encoding: "buffer",
6404
- windowsHide: true,
6405
- maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
6406
- });
6407
- if (res.status !== 0) {
6408
- const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
6409
- throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
6410
- }
6411
- return res.stdout || Buffer.alloc(0);
6412
- }
6413
-
6414
- function normalizeRepoPath(p) {
6415
- if (!p || typeof p !== "string") return null;
6416
- const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
6417
- const parts = normalized.split("/").filter(Boolean);
6418
- if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
6419
- return parts.join("/");
6420
- }
6421
-
6422
- function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
6423
- return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
6424
- }
6394
+ const resolved = path.resolve(mount.path, requestedPath);
6395
+ const normalized = path.normalize(resolved);
6396
+ const relative = path.relative(path.normalize(mount.path), normalized);
6397
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
6398
+ return { error: `Path escapes mount: ${requestedPath}` };
6399
+ }
6400
+ return { resolved: normalized, mount };
6401
+ }
6402
+
6403
+ function checkAccess(mount, flag) {
6404
+ return mount.access.includes(flag);
6405
+ }
6406
+
6407
+ function sha256Hex(value) {
6408
+ return crypto.createHash("sha256").update(value).digest("hex");
6409
+ }
6410
+
6411
+ async function atomicWriteText(filePath, content) {
6412
+ await mkdir(path.dirname(filePath), { recursive: true });
6413
+ const tempPath = path.join(
6414
+ path.dirname(filePath),
6415
+ `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
6416
+ );
6417
+ await writeFile(tempPath, content, "utf8");
6418
+ await rename(tempPath, filePath);
6419
+ }
6420
+
6421
+ async function fileInfo(filePath, requestedPath) {
6422
+ const s = await stat(filePath);
6423
+ const info = {
6424
+ path: requestedPath,
6425
+ type: s.isDirectory() ? "dir" : "file",
6426
+ size: s.size,
6427
+ modified: s.mtime.toISOString(),
6428
+ };
6429
+ if (s.isFile()) {
6430
+ const content = await readFile(filePath);
6431
+ info.sha256 = sha256Hex(content);
6432
+ }
6433
+ return info;
6434
+ }
6435
+
6436
+ const FS_UPLOAD_SESSIONS = new Map();
6437
+ const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
6438
+ const FS_MAX_CHUNKS = 500;
6439
+ const FS_MAX_CHUNK_BYTES = 128 * 1024;
6440
+ const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
6441
+ const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
6442
+ "docs/",
6443
+ ".rdc/plans/",
6444
+ ".rdc/guides/",
6445
+ ".claude/context/",
6446
+ ".claude/rules/",
6447
+ ".rdc/relay/from-claude-ai/",
6448
+ ];
6449
+
6450
+ function cleanupFsUploadSessions() {
6451
+ const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
6452
+ for (const [id, session] of FS_UPLOAD_SESSIONS) {
6453
+ if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
6454
+ }
6455
+ }
6456
+
6457
+ function runGit(cwd, args, opts = {}) {
6458
+ const res = spawnSync("git", args, {
6459
+ cwd,
6460
+ encoding: "utf8",
6461
+ windowsHide: true,
6462
+ maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
6463
+ });
6464
+ if (res.status !== 0) {
6465
+ const detail = (res.stderr || res.stdout || "").trim();
6466
+ throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
6467
+ }
6468
+ return (res.stdout || "").trim();
6469
+ }
6470
+
6471
+ function runGitRaw(cwd, args, opts = {}) {
6472
+ const res = spawnSync("git", args, {
6473
+ cwd,
6474
+ encoding: "buffer",
6475
+ windowsHide: true,
6476
+ maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
6477
+ });
6478
+ if (res.status !== 0) {
6479
+ const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
6480
+ throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
6481
+ }
6482
+ return res.stdout || Buffer.alloc(0);
6483
+ }
6484
+
6485
+ // Read a single credential value from the vault inside an MCP handler.
6486
+ // (The git operations themselves live in ../lib/fs-git.js — pure + testable.)
6487
+ async function vaultRetrieveValue(vault, service) {
6488
+ if (!vault.password) return { error: "locked" };
6489
+ if (vault.whitelist && !vault.whitelist.includes(service.toLowerCase())) return { error: "not_in_whitelist" };
6490
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
6491
+ return api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
6492
+ }
6493
+
6494
+ function normalizeRepoPath(p) {
6495
+ if (!p || typeof p !== "string") return null;
6496
+ const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
6497
+ const parts = normalized.split("/").filter(Boolean);
6498
+ if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
6499
+ return parts.join("/");
6500
+ }
6501
+
6502
+ function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
6503
+ return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
6504
+ }
6425
6505
 
6426
6506
  const MCP_TOOLS = [
6427
6507
  {
@@ -6444,28 +6524,28 @@ const MCP_TOOLS = [
6444
6524
  description: "List all services with type, enabled state, key presence, and last retrieval time",
6445
6525
  inputSchema: { type: "object", properties: {}, additionalProperties: false }
6446
6526
  },
6447
- {
6448
- name: "clauth_list",
6449
- description: "List registered service names",
6450
- inputSchema: { type: "object", properties: {}, additionalProperties: false }
6451
- },
6452
- {
6453
- name: "clauth_search",
6454
- description: "Search registered services by name, label, project, description, type, or redacted address hints from address-bearing secrets",
6455
- inputSchema: {
6456
- type: "object",
6457
- properties: {
6458
- query: { type: "string", description: "Search text, such as part of a service name, project, host, URL, or filesystem path" },
6459
- project: { type: "string", description: "Optional project scope" },
6460
- addresses: { type: "boolean", default: true, description: "Include redacted address hints from connstring/fileserver/oauth secrets" }
6461
- },
6462
- required: ["query"],
6463
- additionalProperties: false
6464
- }
6465
- },
6466
- {
6467
- name: "clauth_get",
6468
- description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
6527
+ {
6528
+ name: "clauth_list",
6529
+ description: "List registered service names",
6530
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
6531
+ },
6532
+ {
6533
+ name: "clauth_search",
6534
+ description: "Search registered services by name, label, project, description, type, or redacted address hints from address-bearing secrets",
6535
+ inputSchema: {
6536
+ type: "object",
6537
+ properties: {
6538
+ query: { type: "string", description: "Search text, such as part of a service name, project, host, URL, or filesystem path" },
6539
+ project: { type: "string", description: "Optional project scope" },
6540
+ addresses: { type: "boolean", default: true, description: "Include redacted address hints from connstring/fileserver/oauth secrets" }
6541
+ },
6542
+ required: ["query"],
6543
+ additionalProperties: false
6544
+ }
6545
+ },
6546
+ {
6547
+ name: "clauth_get",
6548
+ description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
6469
6549
  inputSchema: {
6470
6550
  type: "object",
6471
6551
  properties: {
@@ -6928,9 +7008,9 @@ const MCP_TOOLS = [
6928
7008
  additionalProperties: false,
6929
7009
  },
6930
7010
  },
6931
- {
6932
- name: "fs_write",
6933
- description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
7011
+ {
7012
+ name: "fs_write",
7013
+ description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
6934
7014
  inputSchema: {
6935
7015
  type: "object",
6936
7016
  properties: {
@@ -6939,93 +7019,93 @@ const MCP_TOOLS = [
6939
7019
  mount: { type: "string", description: "Mount name (default: first mount)" },
6940
7020
  },
6941
7021
  required: ["path", "content"],
6942
- additionalProperties: false,
6943
- },
6944
- },
6945
- {
6946
- name: "fs_stat",
6947
- description: "Get file or directory metadata. Files include a SHA-256 hash for guarded edits.",
6948
- inputSchema: {
6949
- type: "object",
6950
- properties: {
6951
- path: { type: "string", description: "Relative path within mount" },
6952
- mount: { type: "string", description: "Mount name (default: first mount)" },
6953
- },
6954
- required: ["path"],
6955
- additionalProperties: false,
6956
- },
6957
- },
6958
- {
6959
- name: "fs_append",
6960
- description: "Append text to a file, optionally guarded by the current file SHA-256 hash.",
6961
- inputSchema: {
6962
- type: "object",
6963
- properties: {
6964
- path: { type: "string", description: "Relative path within mount" },
6965
- content: { type: "string", description: "Text to append" },
6966
- mount: { type: "string", description: "Mount name (default: first mount)" },
6967
- expected_sha256: { type: "string", description: "Optional current file SHA-256. If provided and the file exists, append only when it matches." },
6968
- },
6969
- required: ["path", "content"],
6970
- additionalProperties: false,
6971
- },
6972
- },
6973
- {
6974
- name: "fs_write_chunk",
6975
- description: "Stage chunked text writes and atomically publish when all chunks arrive. Use when single write arguments are too large.",
6976
- inputSchema: {
6977
- type: "object",
6978
- properties: {
6979
- upload_id: { type: "string", description: "Caller-chosen idempotency key for this file upload" },
6980
- path: { type: "string", description: "Relative path within mount" },
6981
- chunk_index: { type: "number", description: "Zero-based chunk index" },
6982
- total_chunks: { type: "number", description: "Total chunks expected" },
6983
- content: { type: "string", description: "Chunk text content" },
6984
- mount: { type: "string", description: "Mount name (default: first mount)" },
6985
- expected_sha256: { type: "string", description: "Optional SHA-256 of the final assembled file content" },
6986
- },
6987
- required: ["upload_id", "path", "chunk_index", "total_chunks", "content"],
6988
- additionalProperties: false,
6989
- },
6990
- },
6991
- {
6992
- name: "fs_ingest_url",
6993
- description: "Download text from an http(s) URL and atomically write it inside the mount. Useful for moving cloud files into the local filesystem.",
6994
- inputSchema: {
6995
- type: "object",
6996
- properties: {
6997
- url: { type: "string", description: "HTTPS or HTTP URL to fetch" },
6998
- path: { type: "string", description: "Relative output path within mount" },
6999
- mount: { type: "string", description: "Mount name (default: first mount)" },
7000
- max_bytes: { type: "number", description: "Maximum download size in bytes (default 5MB, hard max 25MB)" },
7001
- expected_sha256: { type: "string", description: "Optional SHA-256 of fetched content before writing" },
7002
- },
7003
- required: ["url", "path"],
7004
- additionalProperties: false,
7005
- },
7006
- },
7007
- {
7008
- name: "fs_import_git_files",
7009
- description: "Fetch a Git ref and restore only named files into the mounted repo, optionally committing just those files. Designed for Claude.ai GitHub uploads into dirty local monorepos.",
7010
- inputSchema: {
7011
- type: "object",
7012
- properties: {
7013
- remote: { type: "string", description: "Git remote name (default: origin)" },
7014
- ref: { type: "string", description: "Branch, tag, or commit to fetch/restore from" },
7015
- paths: { type: "array", items: { type: "string" }, description: "Repo-relative file paths to import" },
7016
- mode: { type: "string", enum: ["new_only", "overwrite"], description: "new_only refuses existing local paths. overwrite restores named paths only." },
7017
- commit: { type: "boolean", description: "When true, stage only imported paths and create a local commit. Never pushes." },
7018
- message: { type: "string", description: "Commit subject when commit=true" },
7019
- mount: { type: "string", description: "Mount name (default: first mount)" },
7020
- allowed_prefixes: { type: "array", items: { type: "string" }, description: "Optional path allowlist prefixes. Defaults to docs and agent corpus paths." },
7021
- },
7022
- required: ["ref", "paths"],
7023
- additionalProperties: false,
7024
- },
7025
- },
7026
- {
7027
- name: "fs_list",
7028
- description: "List directory contents with file type, size, and modification time.",
7022
+ additionalProperties: false,
7023
+ },
7024
+ },
7025
+ {
7026
+ name: "fs_stat",
7027
+ description: "Get file or directory metadata. Files include a SHA-256 hash for guarded edits.",
7028
+ inputSchema: {
7029
+ type: "object",
7030
+ properties: {
7031
+ path: { type: "string", description: "Relative path within mount" },
7032
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7033
+ },
7034
+ required: ["path"],
7035
+ additionalProperties: false,
7036
+ },
7037
+ },
7038
+ {
7039
+ name: "fs_append",
7040
+ description: "Append text to a file, optionally guarded by the current file SHA-256 hash.",
7041
+ inputSchema: {
7042
+ type: "object",
7043
+ properties: {
7044
+ path: { type: "string", description: "Relative path within mount" },
7045
+ content: { type: "string", description: "Text to append" },
7046
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7047
+ expected_sha256: { type: "string", description: "Optional current file SHA-256. If provided and the file exists, append only when it matches." },
7048
+ },
7049
+ required: ["path", "content"],
7050
+ additionalProperties: false,
7051
+ },
7052
+ },
7053
+ {
7054
+ name: "fs_write_chunk",
7055
+ description: "Stage chunked text writes and atomically publish when all chunks arrive. Use when single write arguments are too large.",
7056
+ inputSchema: {
7057
+ type: "object",
7058
+ properties: {
7059
+ upload_id: { type: "string", description: "Caller-chosen idempotency key for this file upload" },
7060
+ path: { type: "string", description: "Relative path within mount" },
7061
+ chunk_index: { type: "number", description: "Zero-based chunk index" },
7062
+ total_chunks: { type: "number", description: "Total chunks expected" },
7063
+ content: { type: "string", description: "Chunk text content" },
7064
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7065
+ expected_sha256: { type: "string", description: "Optional SHA-256 of the final assembled file content" },
7066
+ },
7067
+ required: ["upload_id", "path", "chunk_index", "total_chunks", "content"],
7068
+ additionalProperties: false,
7069
+ },
7070
+ },
7071
+ {
7072
+ name: "fs_ingest_url",
7073
+ description: "Download text from an http(s) URL and atomically write it inside the mount. Useful for moving cloud files into the local filesystem.",
7074
+ inputSchema: {
7075
+ type: "object",
7076
+ properties: {
7077
+ url: { type: "string", description: "HTTPS or HTTP URL to fetch" },
7078
+ path: { type: "string", description: "Relative output path within mount" },
7079
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7080
+ max_bytes: { type: "number", description: "Maximum download size in bytes (default 5MB, hard max 25MB)" },
7081
+ expected_sha256: { type: "string", description: "Optional SHA-256 of fetched content before writing" },
7082
+ },
7083
+ required: ["url", "path"],
7084
+ additionalProperties: false,
7085
+ },
7086
+ },
7087
+ {
7088
+ name: "fs_import_git_files",
7089
+ description: "Fetch a Git ref and restore only named files into the mounted repo, optionally committing just those files. Designed for Claude.ai GitHub uploads into dirty local monorepos.",
7090
+ inputSchema: {
7091
+ type: "object",
7092
+ properties: {
7093
+ remote: { type: "string", description: "Git remote name (default: origin)" },
7094
+ ref: { type: "string", description: "Branch, tag, or commit to fetch/restore from" },
7095
+ paths: { type: "array", items: { type: "string" }, description: "Repo-relative file paths to import" },
7096
+ mode: { type: "string", enum: ["new_only", "overwrite"], description: "new_only refuses existing local paths. overwrite restores named paths only." },
7097
+ commit: { type: "boolean", description: "When true, stage only imported paths and create a local commit. Never pushes." },
7098
+ message: { type: "string", description: "Commit subject when commit=true" },
7099
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7100
+ allowed_prefixes: { type: "array", items: { type: "string" }, description: "Optional path allowlist prefixes. Defaults to docs and agent corpus paths." },
7101
+ },
7102
+ required: ["ref", "paths"],
7103
+ additionalProperties: false,
7104
+ },
7105
+ },
7106
+ {
7107
+ name: "fs_list",
7108
+ description: "List directory contents with file type, size, and modification time.",
7029
7109
  inputSchema: {
7030
7110
  type: "object",
7031
7111
  properties: {
@@ -7092,13 +7172,115 @@ const MCP_TOOLS = [
7092
7172
  additionalProperties: false,
7093
7173
  },
7094
7174
  },
7175
+ {
7176
+ name: "fs_edit",
7177
+ description: "Replace a unique string in a file. Like Edit in Claude Code: fails if old_string is not found or matches multiple times unless replace_all=true. Atomic write. Returns new file info including sha256.",
7178
+ inputSchema: {
7179
+ type: "object",
7180
+ properties: {
7181
+ path: { type: "string", description: "Relative path within mount" },
7182
+ old_string: { type: "string", description: "Exact text to find (must be unique unless replace_all=true)" },
7183
+ new_string: { type: "string", description: "Replacement text (must differ from old_string)" },
7184
+ replace_all: { type: "boolean", description: "Replace every occurrence (default false)" },
7185
+ expected_sha256: { type: "string", description: "Optional current file SHA-256 guard. If provided and mismatched, edit is rejected." },
7186
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7187
+ },
7188
+ required: ["path", "old_string", "new_string"],
7189
+ additionalProperties: false,
7190
+ },
7191
+ },
7192
+ {
7193
+ name: "fs_move",
7194
+ description: "Move or rename a file or directory within a mount. Single syscall when possible; falls back to copy+delete across devices. Refuses to overwrite existing destination unless overwrite=true.",
7195
+ inputSchema: {
7196
+ type: "object",
7197
+ properties: {
7198
+ from: { type: "string", description: "Source relative path within mount" },
7199
+ to: { type: "string", description: "Destination relative path within mount" },
7200
+ overwrite: { type: "boolean", description: "Overwrite destination if it exists (default false)" },
7201
+ mount: { type: "string", description: "Mount name for both from and to (default: first mount)" },
7202
+ },
7203
+ required: ["from", "to"],
7204
+ additionalProperties: false,
7205
+ },
7206
+ },
7207
+ {
7208
+ name: "fs_copy",
7209
+ description: "Copy a file or directory tree within a mount. Recursive by default. Refuses to overwrite existing destination unless overwrite=true.",
7210
+ inputSchema: {
7211
+ type: "object",
7212
+ properties: {
7213
+ from: { type: "string", description: "Source relative path within mount" },
7214
+ to: { type: "string", description: "Destination relative path within mount" },
7215
+ overwrite: { type: "boolean", description: "Overwrite destination if it exists (default false)" },
7216
+ mount: { type: "string", description: "Mount name for both from and to (default: first mount)" },
7217
+ },
7218
+ required: ["from", "to"],
7219
+ additionalProperties: false,
7220
+ },
7221
+ },
7095
7222
  {
7096
7223
  name: "fs_mounts",
7097
7224
  description: "List configured filesystem mounts (fileserver services from vault).",
7098
7225
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
7099
7226
  },
7227
+ {
7228
+ name: "fs_repo_status",
7229
+ description: "Get the current git state of the mounted repo in ONE call: branch, HEAD sha + subject, whether the tree is clean or dirty (with changed paths), staged paths, how far ahead/behind the remote you are, and whether a merge or rebase is in progress. Call this first so you KNOW what you are working with before committing — you never have to guess whether your edits or a previous push landed. Requires 'g' (git) access on the mount.",
7230
+ inputSchema: {
7231
+ type: "object",
7232
+ properties: {
7233
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7234
+ },
7235
+ additionalProperties: false,
7236
+ },
7237
+ },
7238
+ {
7239
+ name: "fs_use_branch",
7240
+ description: "Safely make sure you are on a branch before committing. Switches to an existing branch, or creates a new one from the current HEAD when create=true. Your uncommitted edits are carried forward; if switching would overwrite local changes it refuses and leaves the tree untouched, telling you which files block the switch. Refuses while a merge/rebase is in progress. Requires 'g' (git) access on the mount.",
7241
+ inputSchema: {
7242
+ type: "object",
7243
+ properties: {
7244
+ branch: { type: "string", description: "Branch name to switch to (or create)" },
7245
+ create: { type: "boolean", description: "Create a new branch from current HEAD instead of switching to an existing one (default false)" },
7246
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7247
+ },
7248
+ required: ["branch"],
7249
+ additionalProperties: false,
7250
+ },
7251
+ },
7252
+ {
7253
+ name: "fs_commit",
7254
+ description: "Save the files you edited durably: stages changes, commits, and PUSHES by default — usually a single call is all you need after editing. Handles the messy cases for you: nothing-to-commit returns cleanly (no error); a merge/rebase in progress or a detached HEAD is refused with a clear message; if the branch is behind the remote it auto-rebases and retries the push, and on conflict it aborts the rebase (restoring your tree) and keeps the commit local so nothing is lost. Refuses to push protected branches (main/master/production) — those are promoted by a human. Returns the commit sha, the exact files committed, whether the push succeeded, and ahead/behind counts, so you never need a follow-up status call. Push auth uses the vault's github token directly (never exposed). Requires 'g' (git) access on the mount.",
7255
+ inputSchema: {
7256
+ type: "object",
7257
+ properties: {
7258
+ message: { type: "string", description: "Commit message (required)" },
7259
+ paths: { type: "array", items: { type: "string" }, description: "Repo-relative paths to commit. Omit to commit ALL current changes in the repo." },
7260
+ push: { type: "boolean", description: "Push to the remote after committing (default true)" },
7261
+ remote: { type: "string", description: "Git remote name (default: origin)" },
7262
+ author_name: { type: "string", description: "Commit author name (default: clauth-fs)" },
7263
+ author_email: { type: "string", description: "Commit author email (default: fs@clauth.local)" },
7264
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7265
+ },
7266
+ required: ["message"],
7267
+ additionalProperties: false,
7268
+ },
7269
+ },
7100
7270
  ];
7101
7271
 
7272
+ const MCP_WRITE_TOOL_NAMES = new Set([
7273
+ "clauth_enable",
7274
+ "clauth_disable",
7275
+ "clauth_set_project",
7276
+ "clauth_generate_token",
7277
+ ]);
7278
+
7279
+ function filterMcpToolsForWriteMode(tools) {
7280
+ if (process.env.CLAUTH_MCP_WRITE === "1") return tools;
7281
+ return tools.filter(tool => !MCP_WRITE_TOOL_NAMES.has(tool.name));
7282
+ }
7283
+
7102
7284
  function writeTempSecret(service, value) {
7103
7285
  const filePath = path.join(os.tmpdir(), `.clauth-${service}`);
7104
7286
  fs.writeFileSync(filePath, value, { mode: 0o600 });
@@ -7129,6 +7311,11 @@ function mcpError(text) {
7129
7311
  const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
7130
7312
 
7131
7313
  async function handleMcpTool(vault, name, args) {
7314
+ const requireMcpWrite = () => {
7315
+ if (vault.writeEnabled) return null;
7316
+ return mcpError("MCP write tools are disabled by default. Launch clauth with CLAUTH_MCP_WRITE=1 for an explicit write-capable session.");
7317
+ };
7318
+
7132
7319
  switch (name) {
7133
7320
  case "clauth_ping": {
7134
7321
  return mcpResult(
@@ -7182,10 +7369,10 @@ async function handleMcpTool(vault, name, args) {
7182
7369
  }
7183
7370
  }
7184
7371
 
7185
- case "clauth_list": {
7186
- if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7187
- try {
7188
- const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
7372
+ case "clauth_list": {
7373
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7374
+ try {
7375
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
7189
7376
  const result = await api.status(vault.password, vault.machineHash, token, timestamp);
7190
7377
  if (result.error) return mcpError(result.error);
7191
7378
  let services = result.services || [];
@@ -7194,34 +7381,34 @@ async function handleMcpTool(vault, name, args) {
7194
7381
  }
7195
7382
  return mcpResult(services.map(s => s.name).join(", "));
7196
7383
  } catch (err) {
7197
- return mcpError(err.message);
7198
- }
7199
- }
7200
-
7201
- case "clauth_search": {
7202
- if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7203
- try {
7204
- const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
7205
- const result = await searchServices({
7206
- password: vault.password,
7207
- machineHash: vault.machineHash,
7208
- token,
7209
- timestamp,
7210
- query: args.query,
7211
- project: args.project,
7212
- includeAddresses: args.addresses !== false,
7213
- whitelist: vault.whitelist
7214
- });
7215
- if (result.error) return mcpError(result.error);
7216
- return mcpResult(JSON.stringify(result, null, 2));
7217
- } catch (err) {
7218
- return mcpError(err.message);
7219
- }
7220
- }
7221
-
7222
- case "clauth_get": {
7223
- if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7224
- const service = (args.service || "").toLowerCase();
7384
+ return mcpError(err.message);
7385
+ }
7386
+ }
7387
+
7388
+ case "clauth_search": {
7389
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7390
+ try {
7391
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
7392
+ const result = await searchServices({
7393
+ password: vault.password,
7394
+ machineHash: vault.machineHash,
7395
+ token,
7396
+ timestamp,
7397
+ query: args.query,
7398
+ project: args.project,
7399
+ includeAddresses: args.addresses !== false,
7400
+ whitelist: vault.whitelist
7401
+ });
7402
+ if (result.error) return mcpError(result.error);
7403
+ return mcpResult(JSON.stringify(result, null, 2));
7404
+ } catch (err) {
7405
+ return mcpError(err.message);
7406
+ }
7407
+ }
7408
+
7409
+ case "clauth_get": {
7410
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7411
+ const service = (args.service || "").toLowerCase();
7225
7412
  const target = args.target || "file";
7226
7413
  if (!service) return mcpError("service is required");
7227
7414
  if (vault.whitelist && !vault.whitelist.includes(service)) {
@@ -7310,6 +7497,8 @@ async function handleMcpTool(vault, name, args) {
7310
7497
  }
7311
7498
 
7312
7499
  case "clauth_enable": {
7500
+ const writeError = requireMcpWrite();
7501
+ if (writeError) return writeError;
7313
7502
  if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7314
7503
  const service = (args.service || "").toLowerCase();
7315
7504
  if (!service) return mcpError("service is required");
@@ -7324,6 +7513,8 @@ async function handleMcpTool(vault, name, args) {
7324
7513
  }
7325
7514
 
7326
7515
  case "clauth_disable": {
7516
+ const writeError = requireMcpWrite();
7517
+ if (writeError) return writeError;
7327
7518
  if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7328
7519
  const service = (args.service || "").toLowerCase();
7329
7520
  if (!service) return mcpError("service is required");
@@ -7363,6 +7554,8 @@ async function handleMcpTool(vault, name, args) {
7363
7554
  }
7364
7555
 
7365
7556
  case "clauth_set_project": {
7557
+ const writeError = requireMcpWrite();
7558
+ if (writeError) return writeError;
7366
7559
  if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7367
7560
  const service = (args.service || "").toLowerCase();
7368
7561
  const project = args.project;
@@ -7414,6 +7607,8 @@ async function handleMcpTool(vault, name, args) {
7414
7607
  }
7415
7608
 
7416
7609
  case "clauth_generate_token": {
7610
+ const writeError = requireMcpWrite();
7611
+ if (writeError) return writeError;
7417
7612
  if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7418
7613
  const service = (args.service || "").trim().toLowerCase();
7419
7614
  const prefix = args.prefix || "";
@@ -7421,8 +7616,14 @@ async function handleMcpTool(vault, name, args) {
7421
7616
  try {
7422
7617
  const randomHex = crypto.randomBytes(32).toString("hex");
7423
7618
  const generatedToken = `${prefix}${randomHex}`;
7424
- const { token: authToken, timestamp } = deriveToken(vault.password, vault.machineHash);
7425
- const result = await api.write(vault.password, vault.machineHash, authToken, timestamp, service, generatedToken);
7619
+ const { result } = await writeCredentialWithRecovery({
7620
+ password: vault.password,
7621
+ machineHash: vault.machineHash,
7622
+ service,
7623
+ value: generatedToken,
7624
+ logFile: LOG_FILE,
7625
+ normalize: false,
7626
+ });
7426
7627
  if (result.error) return mcpError(result.error);
7427
7628
  return mcpResult(`Token generated and stored under "${service}": ${generatedToken}`);
7428
7629
  } catch (err) {
@@ -7515,250 +7716,250 @@ async function handleMcpTool(vault, name, args) {
7515
7716
  }
7516
7717
  }
7517
7718
 
7518
- case "fs_write": {
7519
- const r = await resolveInMount(args.path, args.mount, vault);
7520
- if (r.error) return mcpError(r.error);
7521
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7522
- try {
7523
- await atomicWriteText(r.resolved, args.content);
7524
- return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
7525
- } catch (err) {
7526
- return mcpError(`Write failed: ${err.message}`);
7527
- }
7528
- }
7529
-
7530
- case "fs_stat": {
7531
- const r = await resolveInMount(args.path, args.mount, vault);
7532
- if (r.error) return mcpError(r.error);
7533
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
7534
- try {
7535
- return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
7536
- } catch (err) {
7537
- if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
7538
- return mcpError(`Stat failed: ${err.message}`);
7539
- }
7540
- }
7541
-
7542
- case "fs_append": {
7543
- const r = await resolveInMount(args.path, args.mount, vault);
7544
- if (r.error) return mcpError(r.error);
7545
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7546
- try {
7547
- try {
7548
- const current = await readFile(r.resolved);
7549
- if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
7550
- return mcpError("Append rejected: current file hash does not match expected_sha256");
7551
- }
7552
- } catch (err) {
7553
- if (err.code !== "ENOENT") throw err;
7554
- if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
7555
- }
7556
- await mkdir(path.dirname(r.resolved), { recursive: true });
7557
- await appendFile(r.resolved, args.content, "utf8");
7558
- const info = await fileInfo(r.resolved, args.path);
7559
- return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
7560
- } catch (err) {
7561
- return mcpError(`Append failed: ${err.message}`);
7562
- }
7563
- }
7564
-
7565
- case "fs_write_chunk": {
7566
- cleanupFsUploadSessions();
7567
- const { upload_id, chunk_index, total_chunks, content } = args;
7568
- const index = Number(chunk_index);
7569
- const total = Number(total_chunks);
7570
- if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
7571
- return mcpError("Invalid chunk_index/total_chunks");
7572
- }
7573
- if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
7574
- if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
7575
- return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
7576
- }
7577
-
7578
- const r = await resolveInMount(args.path, args.mount, vault);
7579
- if (r.error) return mcpError(r.error);
7580
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7581
-
7582
- const key = `${r.mount.name}:${args.path}:${upload_id}`;
7583
- let session = FS_UPLOAD_SESSIONS.get(key);
7584
- if (!session) {
7585
- session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
7586
- FS_UPLOAD_SESSIONS.set(key, session);
7587
- }
7588
- if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
7589
- return mcpError("Upload id collision: path or total_chunks differs from existing session");
7590
- }
7591
- if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
7592
- return mcpError("Upload id collision: expected_sha256 differs from existing session");
7593
- }
7594
-
7595
- session.chunks.set(index, content);
7596
- session.updatedAt = Date.now();
7597
-
7598
- if (session.chunks.size < total) {
7599
- return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
7600
- }
7601
-
7602
- const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
7603
- const actualSha = sha256Hex(assembled);
7604
- if (session.expectedSha256 && actualSha !== session.expectedSha256) {
7605
- FS_UPLOAD_SESSIONS.delete(key);
7606
- return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
7607
- }
7608
-
7609
- try {
7610
- await atomicWriteText(r.resolved, assembled);
7611
- FS_UPLOAD_SESSIONS.delete(key);
7612
- return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
7613
- } catch (err) {
7614
- return mcpError(`Chunked write failed: ${err.message}`);
7615
- }
7616
- }
7617
-
7618
- case "fs_ingest_url": {
7619
- const r = await resolveInMount(args.path, args.mount, vault);
7620
- if (r.error) return mcpError(r.error);
7621
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7622
-
7623
- let url;
7624
- try {
7625
- url = new URL(args.url);
7626
- } catch {
7627
- return mcpError("Invalid URL");
7628
- }
7629
- if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
7630
-
7631
- const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
7632
- try {
7633
- const response = await fetch(url, { redirect: "follow" });
7634
- if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
7635
- const length = Number(response.headers.get("content-length") || 0);
7636
- if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
7637
-
7638
- const reader = response.body?.getReader();
7639
- if (!reader) return mcpError("Fetch failed: response body is not readable");
7640
-
7641
- let received = 0;
7642
- const chunks = [];
7643
- while (true) {
7644
- const { done, value } = await reader.read();
7645
- if (done) break;
7646
- received += value.byteLength;
7647
- if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
7648
- chunks.push(Buffer.from(value));
7649
- }
7650
-
7651
- const content = Buffer.concat(chunks).toString("utf8");
7652
- const actualSha = sha256Hex(content);
7653
- if (args.expected_sha256 && actualSha !== args.expected_sha256) {
7654
- return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
7655
- }
7656
- await atomicWriteText(r.resolved, content);
7657
- return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
7658
- } catch (err) {
7659
- return mcpError(`Ingest failed: ${err.message}`);
7660
- }
7661
- }
7662
-
7663
- case "fs_import_git_files": {
7664
- if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
7665
- if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
7666
-
7667
- const r = await resolveInMount(".", args.mount, vault);
7668
- if (r.error) return mcpError(r.error);
7669
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7670
-
7671
- const repoRoot = r.resolved;
7672
- const remote = args.remote || "origin";
7673
- const mode = args.mode || "new_only";
7674
- const doCommit = args.commit === true;
7675
- const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
7676
- ? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
7677
- : FS_GIT_IMPORT_ALLOWED_PREFIXES;
7678
-
7679
- try {
7680
- const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
7681
- if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
7682
- return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
7683
- }
7684
-
7685
- const normalizedPaths = [];
7686
- for (const rawPath of args.paths) {
7687
- const normalized = normalizeRepoPath(rawPath);
7688
- if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
7689
- if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
7690
- normalizedPaths.push(normalized);
7691
- }
7692
-
7693
- if (mode === "new_only") {
7694
- for (const rel of normalizedPaths) {
7695
- const localPath = path.join(repoRoot, rel);
7696
- try {
7697
- await stat(localPath);
7698
- return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
7699
- } catch (err) {
7700
- if (err.code !== "ENOENT") throw err;
7701
- }
7702
- }
7703
- }
7704
-
7705
- if (doCommit) {
7706
- const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
7707
- if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
7708
- if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
7709
- }
7710
-
7711
- runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
7712
- const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
7713
-
7714
- for (const rel of normalizedPaths) {
7715
- runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
7716
- }
7717
-
7718
- runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
7719
-
7720
- const imported = [];
7721
- for (const rel of normalizedPaths) {
7722
- const localPath = path.join(repoRoot, rel);
7723
- const info = await fileInfo(localPath, rel);
7724
- const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
7725
- const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
7726
- imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
7727
- }
7728
-
7729
- let localCommit = null;
7730
- if (doCommit) {
7731
- runGit(repoRoot, ["add", "--", ...normalizedPaths]);
7732
- const body = [
7733
- args.message.trim(),
7734
- "",
7735
- "Imported from Claude.ai GitHub upload.",
7736
- "",
7737
- `Source remote: ${remote}`,
7738
- `Source ref: ${args.ref}`,
7739
- `Source commit: ${sourceCommit}`,
7740
- "",
7741
- "Paths:",
7742
- ...normalizedPaths.map((p) => `- ${p}`),
7743
- ].join("\n");
7744
- runGit(repoRoot, ["commit", "-m", body]);
7745
- localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
7746
- }
7747
-
7748
- return mcpResult(JSON.stringify({
7749
- status: "ok",
7750
- mode,
7751
- committed: doCommit,
7752
- source_commit: sourceCommit,
7753
- local_commit: localCommit,
7754
- imported,
7755
- }, null, 2));
7756
- } catch (err) {
7757
- return mcpError(`Git import failed: ${err.message}`);
7758
- }
7759
- }
7760
-
7761
- case "fs_list": {
7719
+ case "fs_write": {
7720
+ const r = await resolveInMount(args.path, args.mount, vault);
7721
+ if (r.error) return mcpError(r.error);
7722
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7723
+ try {
7724
+ await atomicWriteText(r.resolved, args.content);
7725
+ return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
7726
+ } catch (err) {
7727
+ return mcpError(`Write failed: ${err.message}`);
7728
+ }
7729
+ }
7730
+
7731
+ case "fs_stat": {
7732
+ const r = await resolveInMount(args.path, args.mount, vault);
7733
+ if (r.error) return mcpError(r.error);
7734
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
7735
+ try {
7736
+ return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
7737
+ } catch (err) {
7738
+ if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
7739
+ return mcpError(`Stat failed: ${err.message}`);
7740
+ }
7741
+ }
7742
+
7743
+ case "fs_append": {
7744
+ const r = await resolveInMount(args.path, args.mount, vault);
7745
+ if (r.error) return mcpError(r.error);
7746
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7747
+ try {
7748
+ try {
7749
+ const current = await readFile(r.resolved);
7750
+ if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
7751
+ return mcpError("Append rejected: current file hash does not match expected_sha256");
7752
+ }
7753
+ } catch (err) {
7754
+ if (err.code !== "ENOENT") throw err;
7755
+ if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
7756
+ }
7757
+ await mkdir(path.dirname(r.resolved), { recursive: true });
7758
+ await appendFile(r.resolved, args.content, "utf8");
7759
+ const info = await fileInfo(r.resolved, args.path);
7760
+ return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
7761
+ } catch (err) {
7762
+ return mcpError(`Append failed: ${err.message}`);
7763
+ }
7764
+ }
7765
+
7766
+ case "fs_write_chunk": {
7767
+ cleanupFsUploadSessions();
7768
+ const { upload_id, chunk_index, total_chunks, content } = args;
7769
+ const index = Number(chunk_index);
7770
+ const total = Number(total_chunks);
7771
+ if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
7772
+ return mcpError("Invalid chunk_index/total_chunks");
7773
+ }
7774
+ if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
7775
+ if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
7776
+ return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
7777
+ }
7778
+
7779
+ const r = await resolveInMount(args.path, args.mount, vault);
7780
+ if (r.error) return mcpError(r.error);
7781
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7782
+
7783
+ const key = `${r.mount.name}:${args.path}:${upload_id}`;
7784
+ let session = FS_UPLOAD_SESSIONS.get(key);
7785
+ if (!session) {
7786
+ session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
7787
+ FS_UPLOAD_SESSIONS.set(key, session);
7788
+ }
7789
+ if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
7790
+ return mcpError("Upload id collision: path or total_chunks differs from existing session");
7791
+ }
7792
+ if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
7793
+ return mcpError("Upload id collision: expected_sha256 differs from existing session");
7794
+ }
7795
+
7796
+ session.chunks.set(index, content);
7797
+ session.updatedAt = Date.now();
7798
+
7799
+ if (session.chunks.size < total) {
7800
+ return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
7801
+ }
7802
+
7803
+ const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
7804
+ const actualSha = sha256Hex(assembled);
7805
+ if (session.expectedSha256 && actualSha !== session.expectedSha256) {
7806
+ FS_UPLOAD_SESSIONS.delete(key);
7807
+ return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
7808
+ }
7809
+
7810
+ try {
7811
+ await atomicWriteText(r.resolved, assembled);
7812
+ FS_UPLOAD_SESSIONS.delete(key);
7813
+ return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
7814
+ } catch (err) {
7815
+ return mcpError(`Chunked write failed: ${err.message}`);
7816
+ }
7817
+ }
7818
+
7819
+ case "fs_ingest_url": {
7820
+ const r = await resolveInMount(args.path, args.mount, vault);
7821
+ if (r.error) return mcpError(r.error);
7822
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7823
+
7824
+ let url;
7825
+ try {
7826
+ url = new URL(args.url);
7827
+ } catch {
7828
+ return mcpError("Invalid URL");
7829
+ }
7830
+ if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
7831
+
7832
+ const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
7833
+ try {
7834
+ const response = await fetch(url, { redirect: "follow" });
7835
+ if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
7836
+ const length = Number(response.headers.get("content-length") || 0);
7837
+ if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
7838
+
7839
+ const reader = response.body?.getReader();
7840
+ if (!reader) return mcpError("Fetch failed: response body is not readable");
7841
+
7842
+ let received = 0;
7843
+ const chunks = [];
7844
+ while (true) {
7845
+ const { done, value } = await reader.read();
7846
+ if (done) break;
7847
+ received += value.byteLength;
7848
+ if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
7849
+ chunks.push(Buffer.from(value));
7850
+ }
7851
+
7852
+ const content = Buffer.concat(chunks).toString("utf8");
7853
+ const actualSha = sha256Hex(content);
7854
+ if (args.expected_sha256 && actualSha !== args.expected_sha256) {
7855
+ return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
7856
+ }
7857
+ await atomicWriteText(r.resolved, content);
7858
+ return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
7859
+ } catch (err) {
7860
+ return mcpError(`Ingest failed: ${err.message}`);
7861
+ }
7862
+ }
7863
+
7864
+ case "fs_import_git_files": {
7865
+ if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
7866
+ if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
7867
+
7868
+ const r = await resolveInMount(".", args.mount, vault);
7869
+ if (r.error) return mcpError(r.error);
7870
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7871
+
7872
+ const repoRoot = r.resolved;
7873
+ const remote = args.remote || "origin";
7874
+ const mode = args.mode || "new_only";
7875
+ const doCommit = args.commit === true;
7876
+ const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
7877
+ ? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
7878
+ : FS_GIT_IMPORT_ALLOWED_PREFIXES;
7879
+
7880
+ try {
7881
+ const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
7882
+ if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
7883
+ return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
7884
+ }
7885
+
7886
+ const normalizedPaths = [];
7887
+ for (const rawPath of args.paths) {
7888
+ const normalized = normalizeRepoPath(rawPath);
7889
+ if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
7890
+ if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
7891
+ normalizedPaths.push(normalized);
7892
+ }
7893
+
7894
+ if (mode === "new_only") {
7895
+ for (const rel of normalizedPaths) {
7896
+ const localPath = path.join(repoRoot, rel);
7897
+ try {
7898
+ await stat(localPath);
7899
+ return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
7900
+ } catch (err) {
7901
+ if (err.code !== "ENOENT") throw err;
7902
+ }
7903
+ }
7904
+ }
7905
+
7906
+ if (doCommit) {
7907
+ const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
7908
+ if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
7909
+ if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
7910
+ }
7911
+
7912
+ runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
7913
+ const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
7914
+
7915
+ for (const rel of normalizedPaths) {
7916
+ runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
7917
+ }
7918
+
7919
+ runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
7920
+
7921
+ const imported = [];
7922
+ for (const rel of normalizedPaths) {
7923
+ const localPath = path.join(repoRoot, rel);
7924
+ const info = await fileInfo(localPath, rel);
7925
+ const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
7926
+ const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
7927
+ imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
7928
+ }
7929
+
7930
+ let localCommit = null;
7931
+ if (doCommit) {
7932
+ runGit(repoRoot, ["add", "--", ...normalizedPaths]);
7933
+ const body = [
7934
+ args.message.trim(),
7935
+ "",
7936
+ "Imported from Claude.ai GitHub upload.",
7937
+ "",
7938
+ `Source remote: ${remote}`,
7939
+ `Source ref: ${args.ref}`,
7940
+ `Source commit: ${sourceCommit}`,
7941
+ "",
7942
+ "Paths:",
7943
+ ...normalizedPaths.map((p) => `- ${p}`),
7944
+ ].join("\n");
7945
+ runGit(repoRoot, ["commit", "-m", body]);
7946
+ localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
7947
+ }
7948
+
7949
+ return mcpResult(JSON.stringify({
7950
+ status: "ok",
7951
+ mode,
7952
+ committed: doCommit,
7953
+ source_commit: sourceCommit,
7954
+ local_commit: localCommit,
7955
+ imported,
7956
+ }, null, 2));
7957
+ } catch (err) {
7958
+ return mcpError(`Git import failed: ${err.message}`);
7959
+ }
7960
+ }
7961
+
7962
+ case "fs_list": {
7762
7963
  const dirPath = args.path || ".";
7763
7964
  const r = await resolveInMount(dirPath, args.mount, vault);
7764
7965
  if (r.error) return mcpError(r.error);
@@ -7881,13 +8082,197 @@ async function handleMcpTool(vault, name, args) {
7881
8082
  }
7882
8083
  }
7883
8084
 
8085
+ case "fs_edit": {
8086
+ if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
8087
+ return mcpError("old_string and new_string must be strings");
8088
+ }
8089
+ if (args.old_string === args.new_string) {
8090
+ return mcpError("old_string and new_string must differ");
8091
+ }
8092
+ if (args.old_string.length === 0) {
8093
+ return mcpError("old_string must not be empty");
8094
+ }
8095
+ const r = await resolveInMount(args.path, args.mount, vault);
8096
+ if (r.error) return mcpError(r.error);
8097
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
8098
+ try {
8099
+ const current = await readFile(r.resolved, "utf8");
8100
+ if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
8101
+ return mcpError("Edit rejected: current file hash does not match expected_sha256");
8102
+ }
8103
+ const parts = current.split(args.old_string);
8104
+ const occurrences = parts.length - 1;
8105
+ if (occurrences === 0) {
8106
+ return mcpError(`Edit failed: old_string not found in ${args.path}`);
8107
+ }
8108
+ if (occurrences > 1 && !args.replace_all) {
8109
+ return mcpError(`Edit failed: old_string is not unique (${occurrences} matches in ${args.path}). Set replace_all=true to replace all, or provide more surrounding context.`);
8110
+ }
8111
+ const updated = args.replace_all
8112
+ ? parts.join(args.new_string)
8113
+ : current.replace(args.old_string, args.new_string);
8114
+ await atomicWriteText(r.resolved, updated);
8115
+ const info = await fileInfo(r.resolved, args.path);
8116
+ return mcpResult(JSON.stringify({ replacements: args.replace_all ? occurrences : 1, ...info }, null, 2));
8117
+ } catch (err) {
8118
+ if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
8119
+ return mcpError(`Edit failed: ${err.message}`);
8120
+ }
8121
+ }
8122
+
8123
+ case "fs_move": {
8124
+ const src = await resolveInMount(args.from, args.mount, vault);
8125
+ if (src.error) return mcpError(src.error);
8126
+ const dst = await resolveInMount(args.to, args.mount, vault);
8127
+ if (dst.error) return mcpError(dst.error);
8128
+ if (!checkAccess(src.mount, "w") || !checkAccess(src.mount, "d")) {
8129
+ return mcpError("Move requires write+delete access on this mount");
8130
+ }
8131
+ try {
8132
+ await stat(src.resolved);
8133
+ } catch (err) {
8134
+ if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
8135
+ return mcpError(`Stat failed: ${err.message}`);
8136
+ }
8137
+ let dstExists = false;
8138
+ try {
8139
+ await stat(dst.resolved);
8140
+ dstExists = true;
8141
+ } catch (err) {
8142
+ if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
8143
+ }
8144
+ if (dstExists && !args.overwrite) {
8145
+ return mcpError(`Move refused: destination already exists: ${args.to} (use overwrite=true)`);
8146
+ }
8147
+ try {
8148
+ await mkdir(path.dirname(dst.resolved), { recursive: true });
8149
+ if (dstExists && args.overwrite) {
8150
+ const dstStat = await stat(dst.resolved);
8151
+ await rm(dst.resolved, { recursive: dstStat.isDirectory(), force: true });
8152
+ }
8153
+ try {
8154
+ await rename(src.resolved, dst.resolved);
8155
+ } catch (err) {
8156
+ if (err.code === "EXDEV") {
8157
+ await cp(src.resolved, dst.resolved, { recursive: true, errorOnExist: false, force: true });
8158
+ const srcStat = await stat(src.resolved);
8159
+ await rm(src.resolved, { recursive: srcStat.isDirectory(), force: true });
8160
+ } else {
8161
+ throw err;
8162
+ }
8163
+ }
8164
+ return mcpResult(JSON.stringify({ status: "moved", from: args.from, to: args.to }, null, 2));
8165
+ } catch (err) {
8166
+ return mcpError(`Move failed: ${err.message}`);
8167
+ }
8168
+ }
8169
+
8170
+ case "fs_copy": {
8171
+ const src = await resolveInMount(args.from, args.mount, vault);
8172
+ if (src.error) return mcpError(src.error);
8173
+ const dst = await resolveInMount(args.to, args.mount, vault);
8174
+ if (dst.error) return mcpError(dst.error);
8175
+ if (!checkAccess(src.mount, "r")) return mcpError("Read access denied on this mount");
8176
+ if (!checkAccess(src.mount, "w")) return mcpError("Write access denied on this mount");
8177
+ let srcStat;
8178
+ try {
8179
+ srcStat = await stat(src.resolved);
8180
+ } catch (err) {
8181
+ if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
8182
+ return mcpError(`Stat failed: ${err.message}`);
8183
+ }
8184
+ let dstExists = false;
8185
+ try {
8186
+ await stat(dst.resolved);
8187
+ dstExists = true;
8188
+ } catch (err) {
8189
+ if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
8190
+ }
8191
+ if (dstExists && !args.overwrite) {
8192
+ return mcpError(`Copy refused: destination already exists: ${args.to} (use overwrite=true)`);
8193
+ }
8194
+ try {
8195
+ await mkdir(path.dirname(dst.resolved), { recursive: true });
8196
+ await cp(src.resolved, dst.resolved, {
8197
+ recursive: true,
8198
+ errorOnExist: false,
8199
+ force: !!args.overwrite,
8200
+ });
8201
+ return mcpResult(JSON.stringify({
8202
+ status: "copied",
8203
+ from: args.from,
8204
+ to: args.to,
8205
+ type: srcStat.isDirectory() ? "dir" : "file",
8206
+ }, null, 2));
8207
+ } catch (err) {
8208
+ return mcpError(`Copy failed: ${err.message}`);
8209
+ }
8210
+ }
8211
+
7884
8212
  case "fs_mounts": {
7885
8213
  const { mounts, error } = await getFileserverMounts(vault);
7886
8214
  if (error) return mcpError(error);
7887
- if (!mounts || mounts.length === 0) return mcpResult("No fileserver mounts configured. Create one:\n1. Use clauth dashboard or clauth_enable to add a service with key_type='fileserver'\n2. Set the secret value to JSON: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwd\"}");
8215
+ if (!mounts || mounts.length === 0) return mcpResult("No fileserver mounts configured. Create one:\n1. Use clauth dashboard or clauth_enable to add a service with key_type='fileserver'\n2. Set the secret value to JSON: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwdg\"} (add 'g' to allow the git verbs: fs_commit, fs_use_branch, fs_repo_status)");
7888
8216
  return mcpResult(JSON.stringify(mounts, null, 2));
7889
8217
  }
7890
8218
 
8219
+ case "fs_repo_status": {
8220
+ const r = await resolveInMount(".", args.mount, vault);
8221
+ if (r.error) return mcpError(r.error);
8222
+ if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
8223
+ try {
8224
+ const { result, error } = await fsGit.repoStatus(r.resolved);
8225
+ if (error) return mcpError(error);
8226
+ return mcpResult(JSON.stringify(result, null, 2));
8227
+ } catch (err) {
8228
+ return mcpError(`Status failed: ${err.message}`);
8229
+ }
8230
+ }
8231
+
8232
+ case "fs_use_branch": {
8233
+ const r = await resolveInMount(".", args.mount, vault);
8234
+ if (r.error) return mcpError(r.error);
8235
+ if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
8236
+ try {
8237
+ const { result, error } = await fsGit.useBranch(r.resolved, args.branch, args.create === true);
8238
+ if (error) return mcpError(error);
8239
+ return mcpResult(JSON.stringify(result, null, 2));
8240
+ } catch (err) {
8241
+ return mcpError(`Branch switch failed: ${err.message}`);
8242
+ }
8243
+ }
8244
+
8245
+ case "fs_commit": {
8246
+ const r = await resolveInMount(".", args.mount, vault);
8247
+ if (r.error) return mcpError(r.error);
8248
+ if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
8249
+ const push = args.push !== false;
8250
+ // Fetch the push token from the vault up front (commit happens first, so
8251
+ // a missing token still preserves the local commit).
8252
+ let token = null, tokenError = null;
8253
+ if (push) {
8254
+ const sec = await vaultRetrieveValue(vault, "github");
8255
+ if (sec.error || !sec.value) tokenError = `could not read the 'github' token (${sec.error || "empty"})`;
8256
+ else token = sec.value;
8257
+ }
8258
+ try {
8259
+ const { result, error } = await fsGit.commit(r.resolved, {
8260
+ message: args.message,
8261
+ paths: args.paths,
8262
+ push,
8263
+ remote: args.remote,
8264
+ token,
8265
+ tokenError,
8266
+ authorName: args.author_name,
8267
+ authorEmail: args.author_email,
8268
+ });
8269
+ if (error) return mcpError(error);
8270
+ return mcpResult(JSON.stringify(result, null, 2));
8271
+ } catch (err) {
8272
+ return mcpError(`Commit failed: ${err.message}`);
8273
+ }
8274
+ }
8275
+
7891
8276
  case "monkey_dispatch": {
7892
8277
  const { prompt, job_id } = args;
7893
8278
  if (!prompt) return mcpError("prompt required");
@@ -8077,6 +8462,7 @@ function createMcpServer(initPassword, whitelist) {
8077
8462
  whitelist,
8078
8463
  failCount: 0,
8079
8464
  MAX_FAILS: 10,
8465
+ writeEnabled: process.env.CLAUTH_MCP_WRITE === "1",
8080
8466
  };
8081
8467
 
8082
8468
  const rl = createInterface({ input: process.stdin, terminal: false });
@@ -8115,7 +8501,7 @@ function createMcpServer(initPassword, whitelist) {
8115
8501
  if (msg.method === "tools/list") {
8116
8502
  return send({
8117
8503
  jsonrpc: "2.0", id,
8118
- result: { tools: MCP_TOOLS }
8504
+ result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
8119
8505
  });
8120
8506
  }
8121
8507