@lifeaitools/clauth 1.5.83 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/serve.js +858 -643
- package/package.json +1 -1
package/cli/commands/serve.js
CHANGED
|
@@ -17,7 +17,7 @@ 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";
|
|
@@ -609,6 +609,11 @@ function dashboardHtml(port, whitelist, isStaged = false) {
|
|
|
609
609
|
.project-tab.active{color:#60a5fa;border-bottom-color:#3b82f6;background:rgba(59,130,246,.08)}
|
|
610
610
|
.project-tab .tab-count{font-size:.7rem;color:#475569;margin-left:4px;font-weight:400}
|
|
611
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}
|
|
612
617
|
.project-edit{display:none;margin-top:8px;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px}
|
|
613
618
|
.project-edit.open{display:flex;gap:6px;align-items:center}
|
|
614
619
|
.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}
|
|
@@ -864,6 +869,11 @@ function dashboardHtml(port, whitelist, isStaged = false) {
|
|
|
864
869
|
</div>
|
|
865
870
|
|
|
866
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>
|
|
867
877
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
868
878
|
<div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
|
|
869
879
|
</div>
|
|
@@ -1160,6 +1170,23 @@ async function stopDaemon() {
|
|
|
1160
1170
|
// ── Load services ───────────────────────────
|
|
1161
1171
|
let allServices = [];
|
|
1162
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
|
+
}
|
|
1163
1190
|
|
|
1164
1191
|
function renderProjectTabs(services) {
|
|
1165
1192
|
const tabsEl = document.getElementById("project-tabs");
|
|
@@ -1199,7 +1226,21 @@ function renderServiceGrid(services) {
|
|
|
1199
1226
|
} else if (activeProjectTab !== "all") {
|
|
1200
1227
|
filtered = services.filter(s => s.project === activeProjectTab);
|
|
1201
1228
|
}
|
|
1202
|
-
|
|
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
|
+
}
|
|
1203
1244
|
grid.innerHTML = filtered.map(s => \`
|
|
1204
1245
|
<div class="card">
|
|
1205
1246
|
<div style="display:flex;align-items:flex-start;justify-content:space-between">
|
|
@@ -5076,8 +5117,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5076
5117
|
}
|
|
5077
5118
|
|
|
5078
5119
|
// POST /update-service — update service metadata (project, label, description)
|
|
5079
|
-
if (method === "POST" && reqPath === "/update-service") {
|
|
5080
|
-
if (lockedGuard(res)) return;
|
|
5120
|
+
if (method === "POST" && reqPath === "/update-service") {
|
|
5121
|
+
if (lockedGuard(res)) return;
|
|
5081
5122
|
|
|
5082
5123
|
let body;
|
|
5083
5124
|
try { body = await readBody(req); } catch {
|
|
@@ -5108,39 +5149,39 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5108
5149
|
return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
|
|
5109
5150
|
} catch (err) {
|
|
5110
5151
|
return strike(res, 502, err.message);
|
|
5111
|
-
}
|
|
5112
|
-
}
|
|
5113
|
-
|
|
5114
|
-
if (method === "GET" && reqPath === "/search") {
|
|
5115
|
-
if (lockedGuard(res)) return;
|
|
5116
|
-
const query = (url.searchParams.get("q") || url.searchParams.get("query") || "").trim();
|
|
5117
|
-
const project = (url.searchParams.get("project") || "").trim() || undefined;
|
|
5118
|
-
const includeAddresses = url.searchParams.get("addresses") !== "false";
|
|
5119
|
-
if (!query) {
|
|
5120
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5121
|
-
return res.end(JSON.stringify({ error: "q query parameter is required" }));
|
|
5122
|
-
}
|
|
5123
|
-
|
|
5124
|
-
try {
|
|
5125
|
-
const { token, timestamp } = deriveToken(password, machineHash);
|
|
5126
|
-
const result = await searchServices({
|
|
5127
|
-
password,
|
|
5128
|
-
machineHash,
|
|
5129
|
-
token,
|
|
5130
|
-
timestamp,
|
|
5131
|
-
project,
|
|
5132
|
-
query,
|
|
5133
|
-
includeAddresses,
|
|
5134
|
-
whitelist
|
|
5135
|
-
});
|
|
5136
|
-
if (result.error) return strike(res, 502, result.error);
|
|
5137
|
-
return ok(res, result);
|
|
5138
|
-
} catch (err) {
|
|
5139
|
-
return strike(res, 502, err.message);
|
|
5140
|
-
}
|
|
5141
|
-
}
|
|
5142
|
-
|
|
5143
|
-
// Unknown route — a wrong URL is not an auth failure. Log it, return 404,
|
|
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,
|
|
5144
5185
|
// but do NOT increment failCount (which locks the vault at MAX_FAILS).
|
|
5145
5186
|
// Auth failures (wrong password, wrong token) still strike via /auth and /get/:service.
|
|
5146
5187
|
try {
|
|
@@ -5511,8 +5552,8 @@ async function actionForeground(opts) {
|
|
|
5511
5552
|
// Reuses the same auth model as the HTTP daemon.
|
|
5512
5553
|
// Secrets are delivered via temp files — never in the MCP response.
|
|
5513
5554
|
|
|
5514
|
-
import { createInterface } from "readline";
|
|
5515
|
-
import { execSync, spawn as spawnProc, spawnSync } from "child_process";
|
|
5555
|
+
import { createInterface } from "readline";
|
|
5556
|
+
import { execSync, spawn as spawnProc, spawnSync } from "child_process";
|
|
5516
5557
|
|
|
5517
5558
|
// ── Monkey dispatch — headless Claude CLI worker ─────────────────
|
|
5518
5559
|
function findClaudeBinary() {
|
|
@@ -6115,9 +6156,9 @@ function stopCodevelopSession(session_id) {
|
|
|
6115
6156
|
return { stopped: true, session_id };
|
|
6116
6157
|
}
|
|
6117
6158
|
|
|
6118
|
-
const ENV_MAP = {
|
|
6119
|
-
"github": "GITHUB_TOKEN",
|
|
6120
|
-
"supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
|
6159
|
+
const ENV_MAP = {
|
|
6160
|
+
"github": "GITHUB_TOKEN",
|
|
6161
|
+
"supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
|
6121
6162
|
"supabase-service": "SUPABASE_SERVICE_ROLE_KEY",
|
|
6122
6163
|
"supabase-db": "SUPABASE_DB_URL",
|
|
6123
6164
|
"vercel": "VERCEL_TOKEN",
|
|
@@ -6129,116 +6170,116 @@ const ENV_MAP = {
|
|
|
6129
6170
|
"rocketreach": "ROCKETREACH_API_KEY",
|
|
6130
6171
|
"npm": "NPM_TOKEN",
|
|
6131
6172
|
"namecheap": "NAMECHEAP_API_KEY",
|
|
6132
|
-
"gmail": "GMAIL_CREDENTIALS",
|
|
6133
|
-
};
|
|
6134
|
-
|
|
6135
|
-
const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
|
|
6136
|
-
const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
|
|
6137
|
-
|
|
6138
|
-
function normalizeSearchText(value) {
|
|
6139
|
-
return String(value || "").toLowerCase();
|
|
6140
|
-
}
|
|
6141
|
-
|
|
6142
|
-
function redactUrlish(value) {
|
|
6143
|
-
const text = String(value || "").trim();
|
|
6144
|
-
if (!text) return "";
|
|
6145
|
-
try {
|
|
6146
|
-
const url = new URL(text);
|
|
6147
|
-
if (url.username) url.username = "***";
|
|
6148
|
-
if (url.password) url.password = "***";
|
|
6149
|
-
return url.toString();
|
|
6150
|
-
} catch {
|
|
6151
|
-
return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
|
|
6152
|
-
}
|
|
6153
|
-
}
|
|
6154
|
-
|
|
6155
|
-
function collectAddressHints(value, keyType) {
|
|
6156
|
-
if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
|
|
6157
|
-
const hints = new Set();
|
|
6158
|
-
|
|
6159
|
-
function add(candidate) {
|
|
6160
|
-
if (candidate === undefined || candidate === null) return;
|
|
6161
|
-
const text = redactUrlish(candidate);
|
|
6162
|
-
if (text) hints.add(text);
|
|
6163
|
-
}
|
|
6164
|
-
|
|
6165
|
-
function walk(node, fieldName = "") {
|
|
6166
|
-
if (node === undefined || node === null) return;
|
|
6167
|
-
if (typeof node === "string") {
|
|
6168
|
-
if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
|
|
6169
|
-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
|
|
6170
|
-
return;
|
|
6171
|
-
}
|
|
6172
|
-
if (Array.isArray(node)) {
|
|
6173
|
-
for (const item of node) walk(item, fieldName);
|
|
6174
|
-
return;
|
|
6175
|
-
}
|
|
6176
|
-
if (typeof node === "object") {
|
|
6177
|
-
for (const [key, child] of Object.entries(node)) walk(child, key);
|
|
6178
|
-
}
|
|
6179
|
-
}
|
|
6180
|
-
|
|
6181
|
-
try {
|
|
6182
|
-
walk(JSON.parse(value));
|
|
6183
|
-
} catch {
|
|
6184
|
-
walk(value);
|
|
6185
|
-
}
|
|
6186
|
-
|
|
6187
|
-
return [...hints];
|
|
6188
|
-
}
|
|
6189
|
-
|
|
6190
|
-
async function searchServices({ password, machineHash, token, timestamp, query, project, includeAddresses = true, whitelist }) {
|
|
6191
|
-
const q = normalizeSearchText(query);
|
|
6192
|
-
if (!q) return { error: "query is required" };
|
|
6193
|
-
|
|
6194
|
-
const result = await api.status(password, machineHash, token, timestamp, project);
|
|
6195
|
-
if (result.error) return { error: result.error };
|
|
6196
|
-
|
|
6197
|
-
let services = result.services || [];
|
|
6198
|
-
if (whitelist) services = services.filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
|
|
6199
|
-
|
|
6200
|
-
const matches = [];
|
|
6201
|
-
for (const s of services) {
|
|
6202
|
-
const fields = {
|
|
6203
|
-
name: s.name,
|
|
6204
|
-
label: s.label,
|
|
6205
|
-
project: s.project,
|
|
6206
|
-
type: s.key_type,
|
|
6207
|
-
description: s.description
|
|
6208
|
-
};
|
|
6209
|
-
const matched = Object.entries(fields)
|
|
6210
|
-
.filter(([, value]) => normalizeSearchText(value).includes(q))
|
|
6211
|
-
.map(([field]) => field);
|
|
6212
|
-
|
|
6213
|
-
let addressHints = [];
|
|
6214
|
-
if (includeAddresses && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && s.vault_key) {
|
|
6215
|
-
const secret = await api.retrieve(password, machineHash, token, timestamp, s.name);
|
|
6216
|
-
if (!secret.error) {
|
|
6217
|
-
addressHints = collectAddressHints(secret.value, s.key_type);
|
|
6218
|
-
if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
|
|
6219
|
-
}
|
|
6220
|
-
}
|
|
6221
|
-
|
|
6222
|
-
if (matched.length) {
|
|
6223
|
-
matches.push({
|
|
6224
|
-
name: s.name,
|
|
6225
|
-
label: s.label || null,
|
|
6226
|
-
project: s.project || null,
|
|
6227
|
-
key_type: s.key_type,
|
|
6228
|
-
enabled: !!s.enabled,
|
|
6229
|
-
has_key: !!s.vault_key,
|
|
6230
|
-
description: s.description || null,
|
|
6231
|
-
matched: [...new Set(matched)],
|
|
6232
|
-
address_hints: addressHints
|
|
6233
|
-
});
|
|
6234
|
-
}
|
|
6235
|
-
}
|
|
6236
|
-
|
|
6237
|
-
return { query, count: matches.length, matches };
|
|
6238
|
-
}
|
|
6239
|
-
|
|
6240
|
-
// ── Filesystem service config — loaded from clauth vault ──
|
|
6241
|
-
let _fsMountsCache = null;
|
|
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;
|
|
6242
6283
|
let _fsMountsCacheTime = 0;
|
|
6243
6284
|
const FS_CACHE_TTL = 60000; // 1 minute
|
|
6244
6285
|
|
|
@@ -6272,115 +6313,115 @@ async function getFileserverMounts(vault) {
|
|
|
6272
6313
|
}
|
|
6273
6314
|
}
|
|
6274
6315
|
|
|
6275
|
-
async function resolveInMount(requestedPath, mountName, vault) {
|
|
6316
|
+
async function resolveInMount(requestedPath, mountName, vault) {
|
|
6276
6317
|
const { mounts, error } = await getFileserverMounts(vault);
|
|
6277
6318
|
if (error) return { error };
|
|
6278
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\"}" };
|
|
6279
6320
|
const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
|
|
6280
6321
|
if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
|
|
6281
6322
|
if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
|
|
6282
|
-
const resolved = path.resolve(mount.path, requestedPath);
|
|
6283
|
-
const normalized = path.normalize(resolved);
|
|
6284
|
-
const relative = path.relative(path.normalize(mount.path), normalized);
|
|
6285
|
-
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
6286
|
-
return { error: `Path escapes mount: ${requestedPath}` };
|
|
6287
|
-
}
|
|
6288
|
-
return { resolved: normalized, mount };
|
|
6289
|
-
}
|
|
6290
|
-
|
|
6291
|
-
function checkAccess(mount, flag) {
|
|
6292
|
-
return mount.access.includes(flag);
|
|
6293
|
-
}
|
|
6294
|
-
|
|
6295
|
-
function sha256Hex(value) {
|
|
6296
|
-
return crypto.createHash("sha256").update(value).digest("hex");
|
|
6297
|
-
}
|
|
6298
|
-
|
|
6299
|
-
async function atomicWriteText(filePath, content) {
|
|
6300
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
6301
|
-
const tempPath = path.join(
|
|
6302
|
-
path.dirname(filePath),
|
|
6303
|
-
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
|
|
6304
|
-
);
|
|
6305
|
-
await writeFile(tempPath, content, "utf8");
|
|
6306
|
-
await rename(tempPath, filePath);
|
|
6307
|
-
}
|
|
6308
|
-
|
|
6309
|
-
async function fileInfo(filePath, requestedPath) {
|
|
6310
|
-
const s = await stat(filePath);
|
|
6311
|
-
const info = {
|
|
6312
|
-
path: requestedPath,
|
|
6313
|
-
type: s.isDirectory() ? "dir" : "file",
|
|
6314
|
-
size: s.size,
|
|
6315
|
-
modified: s.mtime.toISOString(),
|
|
6316
|
-
};
|
|
6317
|
-
if (s.isFile()) {
|
|
6318
|
-
const content = await readFile(filePath);
|
|
6319
|
-
info.sha256 = sha256Hex(content);
|
|
6320
|
-
}
|
|
6321
|
-
return info;
|
|
6322
|
-
}
|
|
6323
|
-
|
|
6324
|
-
const FS_UPLOAD_SESSIONS = new Map();
|
|
6325
|
-
const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
|
|
6326
|
-
const FS_MAX_CHUNKS = 500;
|
|
6327
|
-
const FS_MAX_CHUNK_BYTES = 128 * 1024;
|
|
6328
|
-
const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
|
|
6329
|
-
const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
|
|
6330
|
-
"docs/",
|
|
6331
|
-
".rdc/plans/",
|
|
6332
|
-
".rdc/guides/",
|
|
6333
|
-
".claude/context/",
|
|
6334
|
-
".claude/rules/",
|
|
6335
|
-
".rdc/relay/from-claude-ai/",
|
|
6336
|
-
];
|
|
6337
|
-
|
|
6338
|
-
function cleanupFsUploadSessions() {
|
|
6339
|
-
const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
|
|
6340
|
-
for (const [id, session] of FS_UPLOAD_SESSIONS) {
|
|
6341
|
-
if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
|
|
6342
|
-
}
|
|
6343
|
-
}
|
|
6344
|
-
|
|
6345
|
-
function runGit(cwd, args, opts = {}) {
|
|
6346
|
-
const res = spawnSync("git", args, {
|
|
6347
|
-
cwd,
|
|
6348
|
-
encoding: "utf8",
|
|
6349
|
-
windowsHide: true,
|
|
6350
|
-
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
6351
|
-
});
|
|
6352
|
-
if (res.status !== 0) {
|
|
6353
|
-
const detail = (res.stderr || res.stdout || "").trim();
|
|
6354
|
-
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
6355
|
-
}
|
|
6356
|
-
return (res.stdout || "").trim();
|
|
6357
|
-
}
|
|
6358
|
-
|
|
6359
|
-
function runGitRaw(cwd, args, opts = {}) {
|
|
6360
|
-
const res = spawnSync("git", args, {
|
|
6361
|
-
cwd,
|
|
6362
|
-
encoding: "buffer",
|
|
6363
|
-
windowsHide: true,
|
|
6364
|
-
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
6365
|
-
});
|
|
6366
|
-
if (res.status !== 0) {
|
|
6367
|
-
const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
|
|
6368
|
-
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
6369
|
-
}
|
|
6370
|
-
return res.stdout || Buffer.alloc(0);
|
|
6371
|
-
}
|
|
6372
|
-
|
|
6373
|
-
function normalizeRepoPath(p) {
|
|
6374
|
-
if (!p || typeof p !== "string") return null;
|
|
6375
|
-
const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
6376
|
-
const parts = normalized.split("/").filter(Boolean);
|
|
6377
|
-
if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
|
|
6378
|
-
return parts.join("/");
|
|
6379
|
-
}
|
|
6380
|
-
|
|
6381
|
-
function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
|
|
6382
|
-
return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
|
|
6383
|
-
}
|
|
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
|
+
}
|
|
6384
6425
|
|
|
6385
6426
|
const MCP_TOOLS = [
|
|
6386
6427
|
{
|
|
@@ -6403,28 +6444,28 @@ const MCP_TOOLS = [
|
|
|
6403
6444
|
description: "List all services with type, enabled state, key presence, and last retrieval time",
|
|
6404
6445
|
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
6405
6446
|
},
|
|
6406
|
-
{
|
|
6407
|
-
name: "clauth_list",
|
|
6408
|
-
description: "List registered service names",
|
|
6409
|
-
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
6410
|
-
},
|
|
6411
|
-
{
|
|
6412
|
-
name: "clauth_search",
|
|
6413
|
-
description: "Search registered services by name, label, project, description, type, or redacted address hints from address-bearing secrets",
|
|
6414
|
-
inputSchema: {
|
|
6415
|
-
type: "object",
|
|
6416
|
-
properties: {
|
|
6417
|
-
query: { type: "string", description: "Search text, such as part of a service name, project, host, URL, or filesystem path" },
|
|
6418
|
-
project: { type: "string", description: "Optional project scope" },
|
|
6419
|
-
addresses: { type: "boolean", default: true, description: "Include redacted address hints from connstring/fileserver/oauth secrets" }
|
|
6420
|
-
},
|
|
6421
|
-
required: ["query"],
|
|
6422
|
-
additionalProperties: false
|
|
6423
|
-
}
|
|
6424
|
-
},
|
|
6425
|
-
{
|
|
6426
|
-
name: "clauth_get",
|
|
6427
|
-
description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
|
|
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.",
|
|
6428
6469
|
inputSchema: {
|
|
6429
6470
|
type: "object",
|
|
6430
6471
|
properties: {
|
|
@@ -6887,9 +6928,9 @@ const MCP_TOOLS = [
|
|
|
6887
6928
|
additionalProperties: false,
|
|
6888
6929
|
},
|
|
6889
6930
|
},
|
|
6890
|
-
{
|
|
6891
|
-
name: "fs_write",
|
|
6892
|
-
description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
|
|
6931
|
+
{
|
|
6932
|
+
name: "fs_write",
|
|
6933
|
+
description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
|
|
6893
6934
|
inputSchema: {
|
|
6894
6935
|
type: "object",
|
|
6895
6936
|
properties: {
|
|
@@ -6898,93 +6939,93 @@ const MCP_TOOLS = [
|
|
|
6898
6939
|
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6899
6940
|
},
|
|
6900
6941
|
required: ["path", "content"],
|
|
6901
|
-
additionalProperties: false,
|
|
6902
|
-
},
|
|
6903
|
-
},
|
|
6904
|
-
{
|
|
6905
|
-
name: "fs_stat",
|
|
6906
|
-
description: "Get file or directory metadata. Files include a SHA-256 hash for guarded edits.",
|
|
6907
|
-
inputSchema: {
|
|
6908
|
-
type: "object",
|
|
6909
|
-
properties: {
|
|
6910
|
-
path: { type: "string", description: "Relative path within mount" },
|
|
6911
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6912
|
-
},
|
|
6913
|
-
required: ["path"],
|
|
6914
|
-
additionalProperties: false,
|
|
6915
|
-
},
|
|
6916
|
-
},
|
|
6917
|
-
{
|
|
6918
|
-
name: "fs_append",
|
|
6919
|
-
description: "Append text to a file, optionally guarded by the current file SHA-256 hash.",
|
|
6920
|
-
inputSchema: {
|
|
6921
|
-
type: "object",
|
|
6922
|
-
properties: {
|
|
6923
|
-
path: { type: "string", description: "Relative path within mount" },
|
|
6924
|
-
content: { type: "string", description: "Text to append" },
|
|
6925
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6926
|
-
expected_sha256: { type: "string", description: "Optional current file SHA-256. If provided and the file exists, append only when it matches." },
|
|
6927
|
-
},
|
|
6928
|
-
required: ["path", "content"],
|
|
6929
|
-
additionalProperties: false,
|
|
6930
|
-
},
|
|
6931
|
-
},
|
|
6932
|
-
{
|
|
6933
|
-
name: "fs_write_chunk",
|
|
6934
|
-
description: "Stage chunked text writes and atomically publish when all chunks arrive. Use when single write arguments are too large.",
|
|
6935
|
-
inputSchema: {
|
|
6936
|
-
type: "object",
|
|
6937
|
-
properties: {
|
|
6938
|
-
upload_id: { type: "string", description: "Caller-chosen idempotency key for this file upload" },
|
|
6939
|
-
path: { type: "string", description: "Relative path within mount" },
|
|
6940
|
-
chunk_index: { type: "number", description: "Zero-based chunk index" },
|
|
6941
|
-
total_chunks: { type: "number", description: "Total chunks expected" },
|
|
6942
|
-
content: { type: "string", description: "Chunk text content" },
|
|
6943
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6944
|
-
expected_sha256: { type: "string", description: "Optional SHA-256 of the final assembled file content" },
|
|
6945
|
-
},
|
|
6946
|
-
required: ["upload_id", "path", "chunk_index", "total_chunks", "content"],
|
|
6947
|
-
additionalProperties: false,
|
|
6948
|
-
},
|
|
6949
|
-
},
|
|
6950
|
-
{
|
|
6951
|
-
name: "fs_ingest_url",
|
|
6952
|
-
description: "Download text from an http(s) URL and atomically write it inside the mount. Useful for moving cloud files into the local filesystem.",
|
|
6953
|
-
inputSchema: {
|
|
6954
|
-
type: "object",
|
|
6955
|
-
properties: {
|
|
6956
|
-
url: { type: "string", description: "HTTPS or HTTP URL to fetch" },
|
|
6957
|
-
path: { type: "string", description: "Relative output path within mount" },
|
|
6958
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6959
|
-
max_bytes: { type: "number", description: "Maximum download size in bytes (default 5MB, hard max 25MB)" },
|
|
6960
|
-
expected_sha256: { type: "string", description: "Optional SHA-256 of fetched content before writing" },
|
|
6961
|
-
},
|
|
6962
|
-
required: ["url", "path"],
|
|
6963
|
-
additionalProperties: false,
|
|
6964
|
-
},
|
|
6965
|
-
},
|
|
6966
|
-
{
|
|
6967
|
-
name: "fs_import_git_files",
|
|
6968
|
-
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.",
|
|
6969
|
-
inputSchema: {
|
|
6970
|
-
type: "object",
|
|
6971
|
-
properties: {
|
|
6972
|
-
remote: { type: "string", description: "Git remote name (default: origin)" },
|
|
6973
|
-
ref: { type: "string", description: "Branch, tag, or commit to fetch/restore from" },
|
|
6974
|
-
paths: { type: "array", items: { type: "string" }, description: "Repo-relative file paths to import" },
|
|
6975
|
-
mode: { type: "string", enum: ["new_only", "overwrite"], description: "new_only refuses existing local paths. overwrite restores named paths only." },
|
|
6976
|
-
commit: { type: "boolean", description: "When true, stage only imported paths and create a local commit. Never pushes." },
|
|
6977
|
-
message: { type: "string", description: "Commit subject when commit=true" },
|
|
6978
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6979
|
-
allowed_prefixes: { type: "array", items: { type: "string" }, description: "Optional path allowlist prefixes. Defaults to docs and agent corpus paths." },
|
|
6980
|
-
},
|
|
6981
|
-
required: ["ref", "paths"],
|
|
6982
|
-
additionalProperties: false,
|
|
6983
|
-
},
|
|
6984
|
-
},
|
|
6985
|
-
{
|
|
6986
|
-
name: "fs_list",
|
|
6987
|
-
description: "List directory contents with file type, size, and modification time.",
|
|
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.",
|
|
6988
7029
|
inputSchema: {
|
|
6989
7030
|
type: "object",
|
|
6990
7031
|
properties: {
|
|
@@ -7051,6 +7092,53 @@ const MCP_TOOLS = [
|
|
|
7051
7092
|
additionalProperties: false,
|
|
7052
7093
|
},
|
|
7053
7094
|
},
|
|
7095
|
+
{
|
|
7096
|
+
name: "fs_edit",
|
|
7097
|
+
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.",
|
|
7098
|
+
inputSchema: {
|
|
7099
|
+
type: "object",
|
|
7100
|
+
properties: {
|
|
7101
|
+
path: { type: "string", description: "Relative path within mount" },
|
|
7102
|
+
old_string: { type: "string", description: "Exact text to find (must be unique unless replace_all=true)" },
|
|
7103
|
+
new_string: { type: "string", description: "Replacement text (must differ from old_string)" },
|
|
7104
|
+
replace_all: { type: "boolean", description: "Replace every occurrence (default false)" },
|
|
7105
|
+
expected_sha256: { type: "string", description: "Optional current file SHA-256 guard. If provided and mismatched, edit is rejected." },
|
|
7106
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7107
|
+
},
|
|
7108
|
+
required: ["path", "old_string", "new_string"],
|
|
7109
|
+
additionalProperties: false,
|
|
7110
|
+
},
|
|
7111
|
+
},
|
|
7112
|
+
{
|
|
7113
|
+
name: "fs_move",
|
|
7114
|
+
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.",
|
|
7115
|
+
inputSchema: {
|
|
7116
|
+
type: "object",
|
|
7117
|
+
properties: {
|
|
7118
|
+
from: { type: "string", description: "Source relative path within mount" },
|
|
7119
|
+
to: { type: "string", description: "Destination relative path within mount" },
|
|
7120
|
+
overwrite: { type: "boolean", description: "Overwrite destination if it exists (default false)" },
|
|
7121
|
+
mount: { type: "string", description: "Mount name for both from and to (default: first mount)" },
|
|
7122
|
+
},
|
|
7123
|
+
required: ["from", "to"],
|
|
7124
|
+
additionalProperties: false,
|
|
7125
|
+
},
|
|
7126
|
+
},
|
|
7127
|
+
{
|
|
7128
|
+
name: "fs_copy",
|
|
7129
|
+
description: "Copy a file or directory tree within a mount. Recursive by default. Refuses to overwrite existing destination unless overwrite=true.",
|
|
7130
|
+
inputSchema: {
|
|
7131
|
+
type: "object",
|
|
7132
|
+
properties: {
|
|
7133
|
+
from: { type: "string", description: "Source relative path within mount" },
|
|
7134
|
+
to: { type: "string", description: "Destination relative path within mount" },
|
|
7135
|
+
overwrite: { type: "boolean", description: "Overwrite destination if it exists (default false)" },
|
|
7136
|
+
mount: { type: "string", description: "Mount name for both from and to (default: first mount)" },
|
|
7137
|
+
},
|
|
7138
|
+
required: ["from", "to"],
|
|
7139
|
+
additionalProperties: false,
|
|
7140
|
+
},
|
|
7141
|
+
},
|
|
7054
7142
|
{
|
|
7055
7143
|
name: "fs_mounts",
|
|
7056
7144
|
description: "List configured filesystem mounts (fileserver services from vault).",
|
|
@@ -7141,10 +7229,10 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7141
7229
|
}
|
|
7142
7230
|
}
|
|
7143
7231
|
|
|
7144
|
-
case "clauth_list": {
|
|
7145
|
-
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7146
|
-
try {
|
|
7147
|
-
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7232
|
+
case "clauth_list": {
|
|
7233
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7234
|
+
try {
|
|
7235
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7148
7236
|
const result = await api.status(vault.password, vault.machineHash, token, timestamp);
|
|
7149
7237
|
if (result.error) return mcpError(result.error);
|
|
7150
7238
|
let services = result.services || [];
|
|
@@ -7153,34 +7241,34 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7153
7241
|
}
|
|
7154
7242
|
return mcpResult(services.map(s => s.name).join(", "));
|
|
7155
7243
|
} catch (err) {
|
|
7156
|
-
return mcpError(err.message);
|
|
7157
|
-
}
|
|
7158
|
-
}
|
|
7159
|
-
|
|
7160
|
-
case "clauth_search": {
|
|
7161
|
-
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7162
|
-
try {
|
|
7163
|
-
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7164
|
-
const result = await searchServices({
|
|
7165
|
-
password: vault.password,
|
|
7166
|
-
machineHash: vault.machineHash,
|
|
7167
|
-
token,
|
|
7168
|
-
timestamp,
|
|
7169
|
-
query: args.query,
|
|
7170
|
-
project: args.project,
|
|
7171
|
-
includeAddresses: args.addresses !== false,
|
|
7172
|
-
whitelist: vault.whitelist
|
|
7173
|
-
});
|
|
7174
|
-
if (result.error) return mcpError(result.error);
|
|
7175
|
-
return mcpResult(JSON.stringify(result, null, 2));
|
|
7176
|
-
} catch (err) {
|
|
7177
|
-
return mcpError(err.message);
|
|
7178
|
-
}
|
|
7179
|
-
}
|
|
7180
|
-
|
|
7181
|
-
case "clauth_get": {
|
|
7182
|
-
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7183
|
-
const service = (args.service || "").toLowerCase();
|
|
7244
|
+
return mcpError(err.message);
|
|
7245
|
+
}
|
|
7246
|
+
}
|
|
7247
|
+
|
|
7248
|
+
case "clauth_search": {
|
|
7249
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7250
|
+
try {
|
|
7251
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7252
|
+
const result = await searchServices({
|
|
7253
|
+
password: vault.password,
|
|
7254
|
+
machineHash: vault.machineHash,
|
|
7255
|
+
token,
|
|
7256
|
+
timestamp,
|
|
7257
|
+
query: args.query,
|
|
7258
|
+
project: args.project,
|
|
7259
|
+
includeAddresses: args.addresses !== false,
|
|
7260
|
+
whitelist: vault.whitelist
|
|
7261
|
+
});
|
|
7262
|
+
if (result.error) return mcpError(result.error);
|
|
7263
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
7264
|
+
} catch (err) {
|
|
7265
|
+
return mcpError(err.message);
|
|
7266
|
+
}
|
|
7267
|
+
}
|
|
7268
|
+
|
|
7269
|
+
case "clauth_get": {
|
|
7270
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7271
|
+
const service = (args.service || "").toLowerCase();
|
|
7184
7272
|
const target = args.target || "file";
|
|
7185
7273
|
if (!service) return mcpError("service is required");
|
|
7186
7274
|
if (vault.whitelist && !vault.whitelist.includes(service)) {
|
|
@@ -7474,250 +7562,250 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7474
7562
|
}
|
|
7475
7563
|
}
|
|
7476
7564
|
|
|
7477
|
-
case "fs_write": {
|
|
7478
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7479
|
-
if (r.error) return mcpError(r.error);
|
|
7480
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7481
|
-
try {
|
|
7482
|
-
await atomicWriteText(r.resolved, args.content);
|
|
7483
|
-
return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
|
|
7484
|
-
} catch (err) {
|
|
7485
|
-
return mcpError(`Write failed: ${err.message}`);
|
|
7486
|
-
}
|
|
7487
|
-
}
|
|
7488
|
-
|
|
7489
|
-
case "fs_stat": {
|
|
7490
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7491
|
-
if (r.error) return mcpError(r.error);
|
|
7492
|
-
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
7493
|
-
try {
|
|
7494
|
-
return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
|
|
7495
|
-
} catch (err) {
|
|
7496
|
-
if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
|
|
7497
|
-
return mcpError(`Stat failed: ${err.message}`);
|
|
7498
|
-
}
|
|
7499
|
-
}
|
|
7500
|
-
|
|
7501
|
-
case "fs_append": {
|
|
7502
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7503
|
-
if (r.error) return mcpError(r.error);
|
|
7504
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7505
|
-
try {
|
|
7506
|
-
try {
|
|
7507
|
-
const current = await readFile(r.resolved);
|
|
7508
|
-
if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
|
|
7509
|
-
return mcpError("Append rejected: current file hash does not match expected_sha256");
|
|
7510
|
-
}
|
|
7511
|
-
} catch (err) {
|
|
7512
|
-
if (err.code !== "ENOENT") throw err;
|
|
7513
|
-
if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
|
|
7514
|
-
}
|
|
7515
|
-
await mkdir(path.dirname(r.resolved), { recursive: true });
|
|
7516
|
-
await appendFile(r.resolved, args.content, "utf8");
|
|
7517
|
-
const info = await fileInfo(r.resolved, args.path);
|
|
7518
|
-
return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
|
|
7519
|
-
} catch (err) {
|
|
7520
|
-
return mcpError(`Append failed: ${err.message}`);
|
|
7521
|
-
}
|
|
7522
|
-
}
|
|
7523
|
-
|
|
7524
|
-
case "fs_write_chunk": {
|
|
7525
|
-
cleanupFsUploadSessions();
|
|
7526
|
-
const { upload_id, chunk_index, total_chunks, content } = args;
|
|
7527
|
-
const index = Number(chunk_index);
|
|
7528
|
-
const total = Number(total_chunks);
|
|
7529
|
-
if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
7530
|
-
return mcpError("Invalid chunk_index/total_chunks");
|
|
7531
|
-
}
|
|
7532
|
-
if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
|
|
7533
|
-
if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
|
|
7534
|
-
return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
|
|
7535
|
-
}
|
|
7536
|
-
|
|
7537
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7538
|
-
if (r.error) return mcpError(r.error);
|
|
7539
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7540
|
-
|
|
7541
|
-
const key = `${r.mount.name}:${args.path}:${upload_id}`;
|
|
7542
|
-
let session = FS_UPLOAD_SESSIONS.get(key);
|
|
7543
|
-
if (!session) {
|
|
7544
|
-
session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
|
|
7545
|
-
FS_UPLOAD_SESSIONS.set(key, session);
|
|
7546
|
-
}
|
|
7547
|
-
if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
|
|
7548
|
-
return mcpError("Upload id collision: path or total_chunks differs from existing session");
|
|
7549
|
-
}
|
|
7550
|
-
if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
|
|
7551
|
-
return mcpError("Upload id collision: expected_sha256 differs from existing session");
|
|
7552
|
-
}
|
|
7553
|
-
|
|
7554
|
-
session.chunks.set(index, content);
|
|
7555
|
-
session.updatedAt = Date.now();
|
|
7556
|
-
|
|
7557
|
-
if (session.chunks.size < total) {
|
|
7558
|
-
return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
|
|
7559
|
-
}
|
|
7560
|
-
|
|
7561
|
-
const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
|
|
7562
|
-
const actualSha = sha256Hex(assembled);
|
|
7563
|
-
if (session.expectedSha256 && actualSha !== session.expectedSha256) {
|
|
7564
|
-
FS_UPLOAD_SESSIONS.delete(key);
|
|
7565
|
-
return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
|
|
7566
|
-
}
|
|
7567
|
-
|
|
7568
|
-
try {
|
|
7569
|
-
await atomicWriteText(r.resolved, assembled);
|
|
7570
|
-
FS_UPLOAD_SESSIONS.delete(key);
|
|
7571
|
-
return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
|
|
7572
|
-
} catch (err) {
|
|
7573
|
-
return mcpError(`Chunked write failed: ${err.message}`);
|
|
7574
|
-
}
|
|
7575
|
-
}
|
|
7576
|
-
|
|
7577
|
-
case "fs_ingest_url": {
|
|
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
|
-
let url;
|
|
7583
|
-
try {
|
|
7584
|
-
url = new URL(args.url);
|
|
7585
|
-
} catch {
|
|
7586
|
-
return mcpError("Invalid URL");
|
|
7587
|
-
}
|
|
7588
|
-
if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
|
|
7589
|
-
|
|
7590
|
-
const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
|
|
7591
|
-
try {
|
|
7592
|
-
const response = await fetch(url, { redirect: "follow" });
|
|
7593
|
-
if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
|
|
7594
|
-
const length = Number(response.headers.get("content-length") || 0);
|
|
7595
|
-
if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
|
|
7596
|
-
|
|
7597
|
-
const reader = response.body?.getReader();
|
|
7598
|
-
if (!reader) return mcpError("Fetch failed: response body is not readable");
|
|
7599
|
-
|
|
7600
|
-
let received = 0;
|
|
7601
|
-
const chunks = [];
|
|
7602
|
-
while (true) {
|
|
7603
|
-
const { done, value } = await reader.read();
|
|
7604
|
-
if (done) break;
|
|
7605
|
-
received += value.byteLength;
|
|
7606
|
-
if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
|
|
7607
|
-
chunks.push(Buffer.from(value));
|
|
7608
|
-
}
|
|
7609
|
-
|
|
7610
|
-
const content = Buffer.concat(chunks).toString("utf8");
|
|
7611
|
-
const actualSha = sha256Hex(content);
|
|
7612
|
-
if (args.expected_sha256 && actualSha !== args.expected_sha256) {
|
|
7613
|
-
return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
|
|
7614
|
-
}
|
|
7615
|
-
await atomicWriteText(r.resolved, content);
|
|
7616
|
-
return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
|
|
7617
|
-
} catch (err) {
|
|
7618
|
-
return mcpError(`Ingest failed: ${err.message}`);
|
|
7619
|
-
}
|
|
7620
|
-
}
|
|
7621
|
-
|
|
7622
|
-
case "fs_import_git_files": {
|
|
7623
|
-
if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
|
|
7624
|
-
if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
|
|
7625
|
-
|
|
7626
|
-
const r = await resolveInMount(".", args.mount, vault);
|
|
7627
|
-
if (r.error) return mcpError(r.error);
|
|
7628
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7629
|
-
|
|
7630
|
-
const repoRoot = r.resolved;
|
|
7631
|
-
const remote = args.remote || "origin";
|
|
7632
|
-
const mode = args.mode || "new_only";
|
|
7633
|
-
const doCommit = args.commit === true;
|
|
7634
|
-
const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
|
|
7635
|
-
? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
|
|
7636
|
-
: FS_GIT_IMPORT_ALLOWED_PREFIXES;
|
|
7637
|
-
|
|
7638
|
-
try {
|
|
7639
|
-
const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
7640
|
-
if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
|
|
7641
|
-
return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
|
|
7642
|
-
}
|
|
7643
|
-
|
|
7644
|
-
const normalizedPaths = [];
|
|
7645
|
-
for (const rawPath of args.paths) {
|
|
7646
|
-
const normalized = normalizeRepoPath(rawPath);
|
|
7647
|
-
if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
|
|
7648
|
-
if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
|
|
7649
|
-
normalizedPaths.push(normalized);
|
|
7650
|
-
}
|
|
7651
|
-
|
|
7652
|
-
if (mode === "new_only") {
|
|
7653
|
-
for (const rel of normalizedPaths) {
|
|
7654
|
-
const localPath = path.join(repoRoot, rel);
|
|
7655
|
-
try {
|
|
7656
|
-
await stat(localPath);
|
|
7657
|
-
return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
|
|
7658
|
-
} catch (err) {
|
|
7659
|
-
if (err.code !== "ENOENT") throw err;
|
|
7660
|
-
}
|
|
7661
|
-
}
|
|
7662
|
-
}
|
|
7663
|
-
|
|
7664
|
-
if (doCommit) {
|
|
7665
|
-
const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
7666
|
-
if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
|
|
7667
|
-
if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
|
|
7668
|
-
}
|
|
7669
|
-
|
|
7670
|
-
runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
|
|
7671
|
-
const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
|
|
7672
|
-
|
|
7673
|
-
for (const rel of normalizedPaths) {
|
|
7674
|
-
runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
|
|
7675
|
-
}
|
|
7676
|
-
|
|
7677
|
-
runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
|
|
7678
|
-
|
|
7679
|
-
const imported = [];
|
|
7680
|
-
for (const rel of normalizedPaths) {
|
|
7681
|
-
const localPath = path.join(repoRoot, rel);
|
|
7682
|
-
const info = await fileInfo(localPath, rel);
|
|
7683
|
-
const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
|
|
7684
|
-
const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
|
|
7685
|
-
imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
|
|
7686
|
-
}
|
|
7687
|
-
|
|
7688
|
-
let localCommit = null;
|
|
7689
|
-
if (doCommit) {
|
|
7690
|
-
runGit(repoRoot, ["add", "--", ...normalizedPaths]);
|
|
7691
|
-
const body = [
|
|
7692
|
-
args.message.trim(),
|
|
7693
|
-
"",
|
|
7694
|
-
"Imported from Claude.ai GitHub upload.",
|
|
7695
|
-
"",
|
|
7696
|
-
`Source remote: ${remote}`,
|
|
7697
|
-
`Source ref: ${args.ref}`,
|
|
7698
|
-
`Source commit: ${sourceCommit}`,
|
|
7699
|
-
"",
|
|
7700
|
-
"Paths:",
|
|
7701
|
-
...normalizedPaths.map((p) => `- ${p}`),
|
|
7702
|
-
].join("\n");
|
|
7703
|
-
runGit(repoRoot, ["commit", "-m", body]);
|
|
7704
|
-
localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
7705
|
-
}
|
|
7706
|
-
|
|
7707
|
-
return mcpResult(JSON.stringify({
|
|
7708
|
-
status: "ok",
|
|
7709
|
-
mode,
|
|
7710
|
-
committed: doCommit,
|
|
7711
|
-
source_commit: sourceCommit,
|
|
7712
|
-
local_commit: localCommit,
|
|
7713
|
-
imported,
|
|
7714
|
-
}, null, 2));
|
|
7715
|
-
} catch (err) {
|
|
7716
|
-
return mcpError(`Git import failed: ${err.message}`);
|
|
7717
|
-
}
|
|
7718
|
-
}
|
|
7719
|
-
|
|
7720
|
-
case "fs_list": {
|
|
7565
|
+
case "fs_write": {
|
|
7566
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7567
|
+
if (r.error) return mcpError(r.error);
|
|
7568
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7569
|
+
try {
|
|
7570
|
+
await atomicWriteText(r.resolved, args.content);
|
|
7571
|
+
return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
|
|
7572
|
+
} catch (err) {
|
|
7573
|
+
return mcpError(`Write failed: ${err.message}`);
|
|
7574
|
+
}
|
|
7575
|
+
}
|
|
7576
|
+
|
|
7577
|
+
case "fs_stat": {
|
|
7578
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7579
|
+
if (r.error) return mcpError(r.error);
|
|
7580
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
7581
|
+
try {
|
|
7582
|
+
return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
|
|
7583
|
+
} catch (err) {
|
|
7584
|
+
if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
|
|
7585
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
7586
|
+
}
|
|
7587
|
+
}
|
|
7588
|
+
|
|
7589
|
+
case "fs_append": {
|
|
7590
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7591
|
+
if (r.error) return mcpError(r.error);
|
|
7592
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7593
|
+
try {
|
|
7594
|
+
try {
|
|
7595
|
+
const current = await readFile(r.resolved);
|
|
7596
|
+
if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
|
|
7597
|
+
return mcpError("Append rejected: current file hash does not match expected_sha256");
|
|
7598
|
+
}
|
|
7599
|
+
} catch (err) {
|
|
7600
|
+
if (err.code !== "ENOENT") throw err;
|
|
7601
|
+
if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
|
|
7602
|
+
}
|
|
7603
|
+
await mkdir(path.dirname(r.resolved), { recursive: true });
|
|
7604
|
+
await appendFile(r.resolved, args.content, "utf8");
|
|
7605
|
+
const info = await fileInfo(r.resolved, args.path);
|
|
7606
|
+
return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
|
|
7607
|
+
} catch (err) {
|
|
7608
|
+
return mcpError(`Append failed: ${err.message}`);
|
|
7609
|
+
}
|
|
7610
|
+
}
|
|
7611
|
+
|
|
7612
|
+
case "fs_write_chunk": {
|
|
7613
|
+
cleanupFsUploadSessions();
|
|
7614
|
+
const { upload_id, chunk_index, total_chunks, content } = args;
|
|
7615
|
+
const index = Number(chunk_index);
|
|
7616
|
+
const total = Number(total_chunks);
|
|
7617
|
+
if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
7618
|
+
return mcpError("Invalid chunk_index/total_chunks");
|
|
7619
|
+
}
|
|
7620
|
+
if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
|
|
7621
|
+
if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
|
|
7622
|
+
return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
|
|
7623
|
+
}
|
|
7624
|
+
|
|
7625
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7626
|
+
if (r.error) return mcpError(r.error);
|
|
7627
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7628
|
+
|
|
7629
|
+
const key = `${r.mount.name}:${args.path}:${upload_id}`;
|
|
7630
|
+
let session = FS_UPLOAD_SESSIONS.get(key);
|
|
7631
|
+
if (!session) {
|
|
7632
|
+
session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
|
|
7633
|
+
FS_UPLOAD_SESSIONS.set(key, session);
|
|
7634
|
+
}
|
|
7635
|
+
if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
|
|
7636
|
+
return mcpError("Upload id collision: path or total_chunks differs from existing session");
|
|
7637
|
+
}
|
|
7638
|
+
if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
|
|
7639
|
+
return mcpError("Upload id collision: expected_sha256 differs from existing session");
|
|
7640
|
+
}
|
|
7641
|
+
|
|
7642
|
+
session.chunks.set(index, content);
|
|
7643
|
+
session.updatedAt = Date.now();
|
|
7644
|
+
|
|
7645
|
+
if (session.chunks.size < total) {
|
|
7646
|
+
return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
|
|
7647
|
+
}
|
|
7648
|
+
|
|
7649
|
+
const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
|
|
7650
|
+
const actualSha = sha256Hex(assembled);
|
|
7651
|
+
if (session.expectedSha256 && actualSha !== session.expectedSha256) {
|
|
7652
|
+
FS_UPLOAD_SESSIONS.delete(key);
|
|
7653
|
+
return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
|
|
7654
|
+
}
|
|
7655
|
+
|
|
7656
|
+
try {
|
|
7657
|
+
await atomicWriteText(r.resolved, assembled);
|
|
7658
|
+
FS_UPLOAD_SESSIONS.delete(key);
|
|
7659
|
+
return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
|
|
7660
|
+
} catch (err) {
|
|
7661
|
+
return mcpError(`Chunked write failed: ${err.message}`);
|
|
7662
|
+
}
|
|
7663
|
+
}
|
|
7664
|
+
|
|
7665
|
+
case "fs_ingest_url": {
|
|
7666
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7667
|
+
if (r.error) return mcpError(r.error);
|
|
7668
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7669
|
+
|
|
7670
|
+
let url;
|
|
7671
|
+
try {
|
|
7672
|
+
url = new URL(args.url);
|
|
7673
|
+
} catch {
|
|
7674
|
+
return mcpError("Invalid URL");
|
|
7675
|
+
}
|
|
7676
|
+
if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
|
|
7677
|
+
|
|
7678
|
+
const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
|
|
7679
|
+
try {
|
|
7680
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
7681
|
+
if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
|
|
7682
|
+
const length = Number(response.headers.get("content-length") || 0);
|
|
7683
|
+
if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
|
|
7684
|
+
|
|
7685
|
+
const reader = response.body?.getReader();
|
|
7686
|
+
if (!reader) return mcpError("Fetch failed: response body is not readable");
|
|
7687
|
+
|
|
7688
|
+
let received = 0;
|
|
7689
|
+
const chunks = [];
|
|
7690
|
+
while (true) {
|
|
7691
|
+
const { done, value } = await reader.read();
|
|
7692
|
+
if (done) break;
|
|
7693
|
+
received += value.byteLength;
|
|
7694
|
+
if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
|
|
7695
|
+
chunks.push(Buffer.from(value));
|
|
7696
|
+
}
|
|
7697
|
+
|
|
7698
|
+
const content = Buffer.concat(chunks).toString("utf8");
|
|
7699
|
+
const actualSha = sha256Hex(content);
|
|
7700
|
+
if (args.expected_sha256 && actualSha !== args.expected_sha256) {
|
|
7701
|
+
return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
|
|
7702
|
+
}
|
|
7703
|
+
await atomicWriteText(r.resolved, content);
|
|
7704
|
+
return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
|
|
7705
|
+
} catch (err) {
|
|
7706
|
+
return mcpError(`Ingest failed: ${err.message}`);
|
|
7707
|
+
}
|
|
7708
|
+
}
|
|
7709
|
+
|
|
7710
|
+
case "fs_import_git_files": {
|
|
7711
|
+
if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
|
|
7712
|
+
if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
|
|
7713
|
+
|
|
7714
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
7715
|
+
if (r.error) return mcpError(r.error);
|
|
7716
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7717
|
+
|
|
7718
|
+
const repoRoot = r.resolved;
|
|
7719
|
+
const remote = args.remote || "origin";
|
|
7720
|
+
const mode = args.mode || "new_only";
|
|
7721
|
+
const doCommit = args.commit === true;
|
|
7722
|
+
const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
|
|
7723
|
+
? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
|
|
7724
|
+
: FS_GIT_IMPORT_ALLOWED_PREFIXES;
|
|
7725
|
+
|
|
7726
|
+
try {
|
|
7727
|
+
const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
7728
|
+
if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
|
|
7729
|
+
return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
|
|
7730
|
+
}
|
|
7731
|
+
|
|
7732
|
+
const normalizedPaths = [];
|
|
7733
|
+
for (const rawPath of args.paths) {
|
|
7734
|
+
const normalized = normalizeRepoPath(rawPath);
|
|
7735
|
+
if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
|
|
7736
|
+
if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
|
|
7737
|
+
normalizedPaths.push(normalized);
|
|
7738
|
+
}
|
|
7739
|
+
|
|
7740
|
+
if (mode === "new_only") {
|
|
7741
|
+
for (const rel of normalizedPaths) {
|
|
7742
|
+
const localPath = path.join(repoRoot, rel);
|
|
7743
|
+
try {
|
|
7744
|
+
await stat(localPath);
|
|
7745
|
+
return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
|
|
7746
|
+
} catch (err) {
|
|
7747
|
+
if (err.code !== "ENOENT") throw err;
|
|
7748
|
+
}
|
|
7749
|
+
}
|
|
7750
|
+
}
|
|
7751
|
+
|
|
7752
|
+
if (doCommit) {
|
|
7753
|
+
const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
7754
|
+
if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
|
|
7755
|
+
if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
|
|
7756
|
+
}
|
|
7757
|
+
|
|
7758
|
+
runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
|
|
7759
|
+
const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
|
|
7760
|
+
|
|
7761
|
+
for (const rel of normalizedPaths) {
|
|
7762
|
+
runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
|
|
7763
|
+
}
|
|
7764
|
+
|
|
7765
|
+
runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
|
|
7766
|
+
|
|
7767
|
+
const imported = [];
|
|
7768
|
+
for (const rel of normalizedPaths) {
|
|
7769
|
+
const localPath = path.join(repoRoot, rel);
|
|
7770
|
+
const info = await fileInfo(localPath, rel);
|
|
7771
|
+
const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
|
|
7772
|
+
const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
|
|
7773
|
+
imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
|
|
7774
|
+
}
|
|
7775
|
+
|
|
7776
|
+
let localCommit = null;
|
|
7777
|
+
if (doCommit) {
|
|
7778
|
+
runGit(repoRoot, ["add", "--", ...normalizedPaths]);
|
|
7779
|
+
const body = [
|
|
7780
|
+
args.message.trim(),
|
|
7781
|
+
"",
|
|
7782
|
+
"Imported from Claude.ai GitHub upload.",
|
|
7783
|
+
"",
|
|
7784
|
+
`Source remote: ${remote}`,
|
|
7785
|
+
`Source ref: ${args.ref}`,
|
|
7786
|
+
`Source commit: ${sourceCommit}`,
|
|
7787
|
+
"",
|
|
7788
|
+
"Paths:",
|
|
7789
|
+
...normalizedPaths.map((p) => `- ${p}`),
|
|
7790
|
+
].join("\n");
|
|
7791
|
+
runGit(repoRoot, ["commit", "-m", body]);
|
|
7792
|
+
localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
7793
|
+
}
|
|
7794
|
+
|
|
7795
|
+
return mcpResult(JSON.stringify({
|
|
7796
|
+
status: "ok",
|
|
7797
|
+
mode,
|
|
7798
|
+
committed: doCommit,
|
|
7799
|
+
source_commit: sourceCommit,
|
|
7800
|
+
local_commit: localCommit,
|
|
7801
|
+
imported,
|
|
7802
|
+
}, null, 2));
|
|
7803
|
+
} catch (err) {
|
|
7804
|
+
return mcpError(`Git import failed: ${err.message}`);
|
|
7805
|
+
}
|
|
7806
|
+
}
|
|
7807
|
+
|
|
7808
|
+
case "fs_list": {
|
|
7721
7809
|
const dirPath = args.path || ".";
|
|
7722
7810
|
const r = await resolveInMount(dirPath, args.mount, vault);
|
|
7723
7811
|
if (r.error) return mcpError(r.error);
|
|
@@ -7840,6 +7928,133 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7840
7928
|
}
|
|
7841
7929
|
}
|
|
7842
7930
|
|
|
7931
|
+
case "fs_edit": {
|
|
7932
|
+
if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
|
|
7933
|
+
return mcpError("old_string and new_string must be strings");
|
|
7934
|
+
}
|
|
7935
|
+
if (args.old_string === args.new_string) {
|
|
7936
|
+
return mcpError("old_string and new_string must differ");
|
|
7937
|
+
}
|
|
7938
|
+
if (args.old_string.length === 0) {
|
|
7939
|
+
return mcpError("old_string must not be empty");
|
|
7940
|
+
}
|
|
7941
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7942
|
+
if (r.error) return mcpError(r.error);
|
|
7943
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7944
|
+
try {
|
|
7945
|
+
const current = await readFile(r.resolved, "utf8");
|
|
7946
|
+
if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
|
|
7947
|
+
return mcpError("Edit rejected: current file hash does not match expected_sha256");
|
|
7948
|
+
}
|
|
7949
|
+
const parts = current.split(args.old_string);
|
|
7950
|
+
const occurrences = parts.length - 1;
|
|
7951
|
+
if (occurrences === 0) {
|
|
7952
|
+
return mcpError(`Edit failed: old_string not found in ${args.path}`);
|
|
7953
|
+
}
|
|
7954
|
+
if (occurrences > 1 && !args.replace_all) {
|
|
7955
|
+
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.`);
|
|
7956
|
+
}
|
|
7957
|
+
const updated = args.replace_all
|
|
7958
|
+
? parts.join(args.new_string)
|
|
7959
|
+
: current.replace(args.old_string, args.new_string);
|
|
7960
|
+
await atomicWriteText(r.resolved, updated);
|
|
7961
|
+
const info = await fileInfo(r.resolved, args.path);
|
|
7962
|
+
return mcpResult(JSON.stringify({ replacements: args.replace_all ? occurrences : 1, ...info }, null, 2));
|
|
7963
|
+
} catch (err) {
|
|
7964
|
+
if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
|
|
7965
|
+
return mcpError(`Edit failed: ${err.message}`);
|
|
7966
|
+
}
|
|
7967
|
+
}
|
|
7968
|
+
|
|
7969
|
+
case "fs_move": {
|
|
7970
|
+
const src = await resolveInMount(args.from, args.mount, vault);
|
|
7971
|
+
if (src.error) return mcpError(src.error);
|
|
7972
|
+
const dst = await resolveInMount(args.to, args.mount, vault);
|
|
7973
|
+
if (dst.error) return mcpError(dst.error);
|
|
7974
|
+
if (!checkAccess(src.mount, "w") || !checkAccess(src.mount, "d")) {
|
|
7975
|
+
return mcpError("Move requires write+delete access on this mount");
|
|
7976
|
+
}
|
|
7977
|
+
try {
|
|
7978
|
+
await stat(src.resolved);
|
|
7979
|
+
} catch (err) {
|
|
7980
|
+
if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
|
|
7981
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
7982
|
+
}
|
|
7983
|
+
let dstExists = false;
|
|
7984
|
+
try {
|
|
7985
|
+
await stat(dst.resolved);
|
|
7986
|
+
dstExists = true;
|
|
7987
|
+
} catch (err) {
|
|
7988
|
+
if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
|
|
7989
|
+
}
|
|
7990
|
+
if (dstExists && !args.overwrite) {
|
|
7991
|
+
return mcpError(`Move refused: destination already exists: ${args.to} (use overwrite=true)`);
|
|
7992
|
+
}
|
|
7993
|
+
try {
|
|
7994
|
+
await mkdir(path.dirname(dst.resolved), { recursive: true });
|
|
7995
|
+
if (dstExists && args.overwrite) {
|
|
7996
|
+
const dstStat = await stat(dst.resolved);
|
|
7997
|
+
await rm(dst.resolved, { recursive: dstStat.isDirectory(), force: true });
|
|
7998
|
+
}
|
|
7999
|
+
try {
|
|
8000
|
+
await rename(src.resolved, dst.resolved);
|
|
8001
|
+
} catch (err) {
|
|
8002
|
+
if (err.code === "EXDEV") {
|
|
8003
|
+
await cp(src.resolved, dst.resolved, { recursive: true, errorOnExist: false, force: true });
|
|
8004
|
+
const srcStat = await stat(src.resolved);
|
|
8005
|
+
await rm(src.resolved, { recursive: srcStat.isDirectory(), force: true });
|
|
8006
|
+
} else {
|
|
8007
|
+
throw err;
|
|
8008
|
+
}
|
|
8009
|
+
}
|
|
8010
|
+
return mcpResult(JSON.stringify({ status: "moved", from: args.from, to: args.to }, null, 2));
|
|
8011
|
+
} catch (err) {
|
|
8012
|
+
return mcpError(`Move failed: ${err.message}`);
|
|
8013
|
+
}
|
|
8014
|
+
}
|
|
8015
|
+
|
|
8016
|
+
case "fs_copy": {
|
|
8017
|
+
const src = await resolveInMount(args.from, args.mount, vault);
|
|
8018
|
+
if (src.error) return mcpError(src.error);
|
|
8019
|
+
const dst = await resolveInMount(args.to, args.mount, vault);
|
|
8020
|
+
if (dst.error) return mcpError(dst.error);
|
|
8021
|
+
if (!checkAccess(src.mount, "r")) return mcpError("Read access denied on this mount");
|
|
8022
|
+
if (!checkAccess(src.mount, "w")) return mcpError("Write access denied on this mount");
|
|
8023
|
+
let srcStat;
|
|
8024
|
+
try {
|
|
8025
|
+
srcStat = await stat(src.resolved);
|
|
8026
|
+
} catch (err) {
|
|
8027
|
+
if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
|
|
8028
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
8029
|
+
}
|
|
8030
|
+
let dstExists = false;
|
|
8031
|
+
try {
|
|
8032
|
+
await stat(dst.resolved);
|
|
8033
|
+
dstExists = true;
|
|
8034
|
+
} catch (err) {
|
|
8035
|
+
if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
|
|
8036
|
+
}
|
|
8037
|
+
if (dstExists && !args.overwrite) {
|
|
8038
|
+
return mcpError(`Copy refused: destination already exists: ${args.to} (use overwrite=true)`);
|
|
8039
|
+
}
|
|
8040
|
+
try {
|
|
8041
|
+
await mkdir(path.dirname(dst.resolved), { recursive: true });
|
|
8042
|
+
await cp(src.resolved, dst.resolved, {
|
|
8043
|
+
recursive: true,
|
|
8044
|
+
errorOnExist: false,
|
|
8045
|
+
force: !!args.overwrite,
|
|
8046
|
+
});
|
|
8047
|
+
return mcpResult(JSON.stringify({
|
|
8048
|
+
status: "copied",
|
|
8049
|
+
from: args.from,
|
|
8050
|
+
to: args.to,
|
|
8051
|
+
type: srcStat.isDirectory() ? "dir" : "file",
|
|
8052
|
+
}, null, 2));
|
|
8053
|
+
} catch (err) {
|
|
8054
|
+
return mcpError(`Copy failed: ${err.message}`);
|
|
8055
|
+
}
|
|
8056
|
+
}
|
|
8057
|
+
|
|
7843
8058
|
case "fs_mounts": {
|
|
7844
8059
|
const { mounts, error } = await getFileserverMounts(vault);
|
|
7845
8060
|
if (error) return mcpError(error);
|