@mgsoftwarebv/mg-dashboard-mcp 3.10.1 → 3.11.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/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { createClient } from '@supabase/supabase-js';
14
14
  import { readFile, mkdtemp, writeFile, rm } from 'fs/promises';
15
15
  import { tmpdir } from 'os';
16
16
  import { Client } from 'ssh2';
17
- import { ListObjectsV2Command, DeleteObjectsCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand, HeadObjectCommand, GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
17
+ import { HeadObjectCommand, S3Client, ListObjectsV2Command, DeleteObjectsCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand, GetObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
18
18
 
19
19
  var __defProp = Object.defineProperty;
20
20
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -1373,6 +1373,7 @@ var TOOL_MODULE_MAP = {
1373
1373
  "docker-logs": "ssh_servers",
1374
1374
  "docker-exec": "ssh_servers",
1375
1375
  "docker-compose": "ssh_servers",
1376
+ "wait-for": "ssh_servers",
1376
1377
  "db-discover": "ssh_servers",
1377
1378
  "db-tables": "ssh_servers",
1378
1379
  "db-describe": "ssh_servers",
@@ -1861,7 +1862,12 @@ async function getProxyConnection() {
1861
1862
  async function getServerConnection(serverId) {
1862
1863
  assertServerAccess(serverId);
1863
1864
  const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted, allowed_ssh_ips, os_type").eq("id", serverId).single();
1864
- if (error || !data) throw new Error(`Server not found: ${serverId}`);
1865
+ if (error || !data) {
1866
+ const candidates = Array.from(KNOWN_SERVER_NAMES.values()).flatMap((s) => [s.name, s.id]);
1867
+ const hits = suggestSimilar(serverId, candidates);
1868
+ const hint = hits.length ? ` Did you mean: ${hits.join(", ")}?` : "";
1869
+ throw new Error(`Server not found: ${serverId}.${hint}`);
1870
+ }
1865
1871
  if (!encryptionKey) throw new Error("ENCRYPTION_KEY required to decrypt server credentials");
1866
1872
  const conn = {
1867
1873
  hostname: data.hostname,
@@ -1877,42 +1883,73 @@ async function getServerConnection(serverId) {
1877
1883
  return { conn, proxy, os };
1878
1884
  }
1879
1885
  async function sshExec(opts, command, proxy, options) {
1886
+ const first = await sshExecOnce(opts, command, proxy, options);
1887
+ if (options?.noRetry) return first;
1888
+ if (!isTransientSshError(first.stderr, first.exitCode)) return first;
1889
+ await new Promise((r) => setTimeout(r, 1e3));
1890
+ const second = await sshExecOnce(opts, command, proxy, options);
1891
+ if (second.exitCode === -1 && second.stderr) {
1892
+ second.stderr = `[retry-1 also failed] ${second.stderr}`;
1893
+ }
1894
+ return second;
1895
+ }
1896
+ async function sshExecOnce(opts, command, proxy, options) {
1880
1897
  if (proxy) return sshExecViaProxy(proxy, opts, command, options);
1881
1898
  return new Promise((resolve) => {
1882
1899
  const ssh = new Client();
1883
1900
  let stdout = "";
1884
1901
  let stderr = "";
1885
1902
  let done = false;
1886
- const timeout = opts.timeout || 6e4;
1887
- const timer = setTimeout(() => {
1903
+ const wallTimeout = opts.timeout || 6e4;
1904
+ const idleTimeout = options?.idleTimeoutMs;
1905
+ const wallTimer = setTimeout(() => {
1888
1906
  if (!done) {
1889
1907
  done = true;
1908
+ clearTimeout(idleTimer);
1890
1909
  ssh.end();
1891
- resolve({ stdout, stderr, exitCode: -1 });
1910
+ resolve({ stdout, stderr: stderr || `idle/wall timeout after ${wallTimeout}ms`, exitCode: -1 });
1892
1911
  }
1893
- }, timeout);
1912
+ }, wallTimeout);
1913
+ let idleTimer;
1914
+ const armIdle = () => {
1915
+ if (!idleTimeout) return;
1916
+ if (idleTimer) clearTimeout(idleTimer);
1917
+ idleTimer = setTimeout(() => {
1918
+ if (!done) {
1919
+ done = true;
1920
+ clearTimeout(wallTimer);
1921
+ ssh.end();
1922
+ resolve({ stdout, stderr: stderr || `idle timeout after ${idleTimeout}ms with no output`, exitCode: -1 });
1923
+ }
1924
+ }, idleTimeout);
1925
+ };
1926
+ armIdle();
1894
1927
  ssh.on("ready", () => {
1895
1928
  const execOpts = options?.pty ? { pty: true } : {};
1896
1929
  ssh.exec(command, execOpts, (err, stream) => {
1897
1930
  if (err) {
1898
1931
  if (!done) {
1899
1932
  done = true;
1900
- clearTimeout(timer);
1933
+ clearTimeout(wallTimer);
1934
+ if (idleTimer) clearTimeout(idleTimer);
1901
1935
  ssh.end();
1902
- resolve({ stdout, stderr, exitCode: -1 });
1936
+ resolve({ stdout, stderr: err.message || stderr, exitCode: -1 });
1903
1937
  }
1904
1938
  return;
1905
1939
  }
1906
1940
  stream.on("data", (d) => {
1907
1941
  stdout += d.toString();
1942
+ armIdle();
1908
1943
  });
1909
1944
  stream.stderr.on("data", (d) => {
1910
1945
  stderr += d.toString();
1946
+ armIdle();
1911
1947
  });
1912
1948
  stream.on("close", (code) => {
1913
1949
  if (!done) {
1914
1950
  done = true;
1915
- clearTimeout(timer);
1951
+ clearTimeout(wallTimer);
1952
+ if (idleTimer) clearTimeout(idleTimer);
1916
1953
  ssh.end();
1917
1954
  resolve({ stdout, stderr, exitCode: code ?? 0 });
1918
1955
  }
@@ -1925,7 +1962,8 @@ async function sshExec(opts, command, proxy, options) {
1925
1962
  ssh.on("error", (err) => {
1926
1963
  if (!done) {
1927
1964
  done = true;
1928
- clearTimeout(timer);
1965
+ clearTimeout(wallTimer);
1966
+ if (idleTimer) clearTimeout(idleTimer);
1929
1967
  resolve({ stdout, stderr: err.message, exitCode: -1 });
1930
1968
  }
1931
1969
  });
@@ -1936,7 +1974,7 @@ async function sshExec(opts, command, proxy, options) {
1936
1974
  password: opts.password,
1937
1975
  privateKey: opts.privateKey,
1938
1976
  passphrase: opts.passphrase,
1939
- readyTimeout: timeout
1977
+ readyTimeout: wallTimeout
1940
1978
  });
1941
1979
  });
1942
1980
  }
@@ -1944,16 +1982,35 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
1944
1982
  return new Promise((resolve) => {
1945
1983
  const proxyClient = new Client();
1946
1984
  let done = false;
1947
- const timeout = targetOpts.timeout || 6e4;
1948
- const timer = setTimeout(() => {
1985
+ const wallTimeout = targetOpts.timeout || 6e4;
1986
+ const idleTimeout = options?.idleTimeoutMs;
1987
+ const wallTimer = setTimeout(() => {
1949
1988
  if (!done) {
1950
1989
  done = true;
1990
+ clearTimeout(idleTimer);
1951
1991
  proxyClient.end();
1952
1992
  resolve({ stdout: "", stderr: "SSH proxy command timeout", exitCode: -1 });
1953
1993
  }
1954
- }, timeout);
1994
+ }, wallTimeout);
1995
+ let idleTimer;
1996
+ let stdoutBuf = "";
1997
+ let stderrBuf = "";
1998
+ const armIdle = () => {
1999
+ if (!idleTimeout) return;
2000
+ if (idleTimer) clearTimeout(idleTimer);
2001
+ idleTimer = setTimeout(() => {
2002
+ if (!done) {
2003
+ done = true;
2004
+ clearTimeout(wallTimer);
2005
+ proxyClient.end();
2006
+ resolve({ stdout: stdoutBuf, stderr: stderrBuf || `idle timeout after ${idleTimeout}ms with no output`, exitCode: -1 });
2007
+ }
2008
+ }, idleTimeout);
2009
+ };
2010
+ armIdle();
1955
2011
  const cleanup = () => {
1956
- clearTimeout(timer);
2012
+ clearTimeout(wallTimer);
2013
+ if (idleTimer) clearTimeout(idleTimer);
1957
2014
  proxyClient.end();
1958
2015
  };
1959
2016
  proxyClient.on("ready", () => {
@@ -1967,8 +2024,6 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
1967
2024
  return;
1968
2025
  }
1969
2026
  const targetClient = new Client();
1970
- let stdout = "";
1971
- let stderr = "";
1972
2027
  targetClient.on("ready", () => {
1973
2028
  const execOpts = options?.pty ? { pty: true } : {};
1974
2029
  targetClient.exec(command, execOpts, (execErr, stream) => {
@@ -1977,22 +2032,24 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
1977
2032
  done = true;
1978
2033
  targetClient.end();
1979
2034
  cleanup();
1980
- resolve({ stdout, stderr, exitCode: -1 });
2035
+ resolve({ stdout: stdoutBuf, stderr: stderrBuf, exitCode: -1 });
1981
2036
  }
1982
2037
  return;
1983
2038
  }
1984
2039
  stream.on("data", (d) => {
1985
- stdout += d.toString();
2040
+ stdoutBuf += d.toString();
2041
+ armIdle();
1986
2042
  });
1987
2043
  stream.stderr.on("data", (d) => {
1988
- stderr += d.toString();
2044
+ stderrBuf += d.toString();
2045
+ armIdle();
1989
2046
  });
1990
2047
  stream.on("close", (code) => {
1991
2048
  if (!done) {
1992
2049
  done = true;
1993
2050
  targetClient.end();
1994
2051
  cleanup();
1995
- resolve({ stdout, stderr, exitCode: code ?? 0 });
2052
+ resolve({ stdout: stdoutBuf, stderr: stderrBuf, exitCode: code ?? 0 });
1996
2053
  }
1997
2054
  });
1998
2055
  if (options?.stdin !== void 0) {
@@ -2005,7 +2062,7 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
2005
2062
  done = true;
2006
2063
  targetClient.end();
2007
2064
  cleanup();
2008
- resolve({ stdout, stderr: targetErr.message, exitCode: -1 });
2065
+ resolve({ stdout: stdoutBuf, stderr: targetErr.message, exitCode: -1 });
2009
2066
  }
2010
2067
  });
2011
2068
  targetClient.connect({
@@ -2014,7 +2071,7 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
2014
2071
  password: targetOpts.password,
2015
2072
  privateKey: targetOpts.privateKey,
2016
2073
  passphrase: targetOpts.passphrase,
2017
- readyTimeout: timeout
2074
+ readyTimeout: wallTimeout
2018
2075
  });
2019
2076
  });
2020
2077
  });
@@ -2149,52 +2206,89 @@ async function r2List(bucket, prefix, options) {
2149
2206
  } while (continuationToken && entries.length < options.maxResults);
2150
2207
  return entries;
2151
2208
  }
2209
+ function r2WrapError(bucket, key, e) {
2210
+ const err = e;
2211
+ const status = err?.$metadata?.httpStatusCode;
2212
+ const name = err?.name || "";
2213
+ if (name === "NoSuchKey" || name === "NotFound" || status === 404) {
2214
+ return new Error(`r2://${bucket}/${key} not found`);
2215
+ }
2216
+ if (name === "NoSuchBucket") return new Error(`R2 bucket "${bucket}" not found`);
2217
+ if (status === 403 || name === "AccessDenied") return new Error(`Access denied for r2://${bucket}/${key} (check R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY)`);
2218
+ return new Error(`R2 error (${name || "unknown"}${status ? ` ${status}` : ""}): ${err?.message || String(e)}`);
2219
+ }
2152
2220
  async function r2GetObject(bucket, key, maxBytes) {
2153
2221
  const client = getR2Client();
2154
- const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
2155
- const size = head.ContentLength || 0;
2222
+ let size = 0;
2223
+ try {
2224
+ const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
2225
+ size = head.ContentLength || 0;
2226
+ } catch (e) {
2227
+ throw r2WrapError(bucket, key, e);
2228
+ }
2156
2229
  if (size > maxBytes) {
2157
2230
  throw new Error(`Object too large: ${size} bytes (max ${maxBytes} for inline read; use sftp-write with sourcePath to mirror locally instead, or pass { offset, length } for a ranged read)`);
2158
2231
  }
2159
- const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
2160
- const body = result.Body;
2161
- if (!body?.transformToString) throw new Error("R2 returned no readable body");
2162
- return body.transformToString();
2232
+ try {
2233
+ const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
2234
+ const body = result.Body;
2235
+ if (!body?.transformToString) throw new Error("R2 returned no readable body");
2236
+ return await body.transformToString();
2237
+ } catch (e) {
2238
+ throw r2WrapError(bucket, key, e);
2239
+ }
2163
2240
  }
2164
2241
  async function r2GetObjectRange(bucket, key, range) {
2165
2242
  const client = getR2Client();
2166
- const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
2167
- const size = head.ContentLength || 0;
2243
+ let size = 0;
2244
+ try {
2245
+ const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
2246
+ size = head.ContentLength || 0;
2247
+ } catch (e) {
2248
+ throw r2WrapError(bucket, key, e);
2249
+ }
2168
2250
  if (range.offset >= size && size > 0) throw new Error(`offset ${range.offset} is past end of object (size ${size})`);
2169
2251
  const MAX = 1048576;
2170
2252
  const remaining = Math.max(0, size - range.offset);
2171
2253
  const effectiveLen = range.length !== void 0 ? Math.min(range.length, remaining, MAX) : Math.min(remaining, MAX);
2172
2254
  const end = range.offset + effectiveLen - 1;
2173
- const result = await client.send(new GetObjectCommand({
2174
- Bucket: bucket,
2175
- Key: key,
2176
- Range: `bytes=${range.offset}-${end}`
2177
- }));
2178
- const body = result.Body;
2179
- if (!body?.transformToString) throw new Error("R2 returned no readable body");
2180
- const text = await body.transformToString();
2181
- const header = `# range: bytes ${range.offset}-${end} of ${size} (${effectiveLen} bytes)`;
2182
- return `${header}
2255
+ try {
2256
+ const result = await client.send(new GetObjectCommand({
2257
+ Bucket: bucket,
2258
+ Key: key,
2259
+ Range: `bytes=${range.offset}-${end}`
2260
+ }));
2261
+ const body = result.Body;
2262
+ if (!body?.transformToString) throw new Error("R2 returned no readable body");
2263
+ const text = await body.transformToString();
2264
+ const header = `# range: bytes ${range.offset}-${end} of ${size} (${effectiveLen} bytes)`;
2265
+ return `${header}
2183
2266
  ${text}`;
2267
+ } catch (e) {
2268
+ throw r2WrapError(bucket, key, e);
2269
+ }
2184
2270
  }
2185
2271
  async function r2PutObject(bucket, key, body, contentLength) {
2186
2272
  const client = getR2Client();
2187
- await client.send(new PutObjectCommand({
2188
- Bucket: bucket,
2189
- Key: key,
2190
- Body: body,
2191
- ContentLength: contentLength,
2192
- ContentType: "application/octet-stream"
2193
- }));
2273
+ try {
2274
+ await client.send(new PutObjectCommand({
2275
+ Bucket: bucket,
2276
+ Key: key,
2277
+ Body: body,
2278
+ ContentLength: contentLength,
2279
+ ContentType: "application/octet-stream"
2280
+ }));
2281
+ } catch (e) {
2282
+ throw r2WrapError(bucket, key, e);
2283
+ }
2194
2284
  }
2195
2285
  async function r2DeleteObject(bucket, key) {
2196
2286
  const client = getR2Client();
2197
- await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
2287
+ try {
2288
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
2289
+ } catch (e) {
2290
+ throw r2WrapError(bucket, key, e);
2291
+ }
2198
2292
  }
2199
2293
  async function r2DeletePrefix(bucket, prefix) {
2200
2294
  const client = getR2Client();
@@ -2270,6 +2364,44 @@ async function r2PutObjectMultipart(bucket, key, localPath, totalBytes, onProgre
2270
2364
  throw e;
2271
2365
  }
2272
2366
  }
2367
+ async function r2CopyObject(srcBucket, srcKey, dstBucket, dstKey) {
2368
+ const client = getR2Client();
2369
+ let size = 0;
2370
+ try {
2371
+ const head = await client.send(new HeadObjectCommand({ Bucket: srcBucket, Key: srcKey }));
2372
+ size = head.ContentLength || 0;
2373
+ } catch (e) {
2374
+ throw r2WrapError(srcBucket, srcKey, e);
2375
+ }
2376
+ try {
2377
+ await client.send(new CopyObjectCommand({
2378
+ CopySource: encodeURIComponent(`${srcBucket}/${srcKey}`).replace(/%2F/g, "/"),
2379
+ Bucket: dstBucket,
2380
+ Key: dstKey
2381
+ }));
2382
+ } catch (e) {
2383
+ throw r2WrapError(dstBucket, dstKey, e);
2384
+ }
2385
+ return { size };
2386
+ }
2387
+ async function r2GetObjectStream(bucket, key) {
2388
+ const client = getR2Client();
2389
+ let size = 0;
2390
+ try {
2391
+ const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
2392
+ size = head.ContentLength || 0;
2393
+ } catch (e) {
2394
+ throw r2WrapError(bucket, key, e);
2395
+ }
2396
+ try {
2397
+ const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
2398
+ const body = result.Body;
2399
+ if (!body) throw new Error("R2 returned no readable body");
2400
+ return { stream: body, size };
2401
+ } catch (e) {
2402
+ throw r2WrapError(bucket, key, e);
2403
+ }
2404
+ }
2273
2405
  function sanitizePath(path) {
2274
2406
  let normalized = path.replace(/\\/g, "/").replace(/\0/g, "");
2275
2407
  const parts = normalized.split("/");
@@ -2464,6 +2596,368 @@ function formatBytes(bytes) {
2464
2596
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
2465
2597
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
2466
2598
  }
2599
+ var RESPONSE_MAX_BYTES = 8192;
2600
+ var NO_FOOTER_TOOLS = /* @__PURE__ */ new Set();
2601
+ var TOOL_CACHE_TTL_MS = {
2602
+ "list-servers": 6e4,
2603
+ "docker-list": 3e4
2604
+ };
2605
+ var TOOL_CACHE = /* @__PURE__ */ new Map();
2606
+ function cacheKeyFor(name, args2) {
2607
+ const { noCache: _ignored, ...rest } = args2;
2608
+ const sorted = Object.fromEntries(Object.entries(rest).sort(([a], [b]) => a.localeCompare(b)));
2609
+ return `${name}::${JSON.stringify(sorted)}`;
2610
+ }
2611
+ function getCachedTool(name, args2) {
2612
+ const ttl = TOOL_CACHE_TTL_MS[name];
2613
+ if (!ttl || ttl <= 0) return void 0;
2614
+ if (args2.noCache === true) return void 0;
2615
+ const k = cacheKeyFor(name, args2);
2616
+ const e = TOOL_CACHE.get(k);
2617
+ if (!e) return void 0;
2618
+ if (e.expiresAt < Date.now()) {
2619
+ TOOL_CACHE.delete(k);
2620
+ return void 0;
2621
+ }
2622
+ return e.value;
2623
+ }
2624
+ function setCachedTool(name, args2, value) {
2625
+ const ttl = TOOL_CACHE_TTL_MS[name];
2626
+ if (!ttl || ttl <= 0) return;
2627
+ if (args2.noCache === true) return;
2628
+ TOOL_CACHE.set(cacheKeyFor(name, args2), { value, expiresAt: Date.now() + ttl });
2629
+ }
2630
+ function levenshtein(a, b) {
2631
+ if (a === b) return 0;
2632
+ const m = a.length;
2633
+ const n = b.length;
2634
+ if (m === 0) return n;
2635
+ if (n === 0) return m;
2636
+ const dp = new Array(n + 1);
2637
+ for (let j = 0; j <= n; j++) dp[j] = j;
2638
+ for (let i = 1; i <= m; i++) {
2639
+ let prev = dp[0];
2640
+ dp[0] = i;
2641
+ for (let j = 1; j <= n; j++) {
2642
+ const tmp = dp[j];
2643
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
2644
+ dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
2645
+ prev = tmp;
2646
+ }
2647
+ }
2648
+ return dp[n];
2649
+ }
2650
+ function suggestSimilar(input, candidates, max = 3) {
2651
+ if (!input || !candidates.length) return [];
2652
+ const lo = input.toLowerCase();
2653
+ const threshold = Math.max(2, Math.ceil(input.length / 3));
2654
+ return candidates.map((c) => ({ c, d: levenshtein(lo, c.toLowerCase()) })).filter((x) => x.d <= threshold).sort((a, b) => a.d - b.d).slice(0, max).map((x) => x.c);
2655
+ }
2656
+ var KNOWN_SERVER_NAMES = /* @__PURE__ */ new Map();
2657
+ var KNOWN_CONTAINERS_BY_SERVER = /* @__PURE__ */ new Map();
2658
+ function rememberServers(servers) {
2659
+ for (const s of servers) KNOWN_SERVER_NAMES.set(s.id, s);
2660
+ }
2661
+ function rememberContainers(serverId, names) {
2662
+ KNOWN_CONTAINERS_BY_SERVER.set(serverId, { names, capturedAt: Date.now() });
2663
+ }
2664
+ function buildServerContextHints(servers) {
2665
+ const hints = [];
2666
+ for (const s of servers) {
2667
+ const tagSet = new Set((s.tags || []).map((t) => String(t).toLowerCase()));
2668
+ const name = (s.name || "").toLowerCase();
2669
+ const host = (s.hostname || "").toLowerCase();
2670
+ const found = [];
2671
+ if (tagSet.has("trigger") || /trigger/.test(name) || /trigger/.test(host)) found.push("Trigger.dev self-hosted");
2672
+ if (tagSet.has("supabase") || /supabase/.test(name) || /supabase/.test(host)) found.push("Supabase stack");
2673
+ if (tagSet.has("proxy") || /proxy|jump/.test(name)) found.push("SSH ProxyJump host");
2674
+ if (tagSet.has("giga") || /giga/.test(name)) found.push("shared workloads");
2675
+ if (tagSet.has("vca") || /vca/.test(name)) found.push("VCA hosting");
2676
+ if (tagSet.has("mg-boilers") || /boiler/.test(name)) found.push("boiler API");
2677
+ if (tagSet.has("refront") || /refront|tickets/.test(name) || /refront|tickets/.test(host)) found.push("Refront/tickets");
2678
+ if (found.length) hints.push(`- ${s.name}: ${found.join(", ")}`);
2679
+ }
2680
+ return hints;
2681
+ }
2682
+ function getKnownContainers(serverId, maxAgeMs = 5 * 6e4) {
2683
+ const e = KNOWN_CONTAINERS_BY_SERVER.get(serverId);
2684
+ if (!e) return void 0;
2685
+ if (Date.now() - e.capturedAt > maxAgeMs) return void 0;
2686
+ return e.names;
2687
+ }
2688
+ function truncateForLLM(text, maxBytes) {
2689
+ const totalBytes = Buffer.byteLength(text, "utf8");
2690
+ if (totalBytes <= maxBytes) return { text, truncated: false, totalBytes, shownBytes: totalBytes };
2691
+ const buf = Buffer.from(text, "utf8");
2692
+ let cut = maxBytes;
2693
+ while (cut > 0 && (buf[cut] & 192) === 128) cut--;
2694
+ const head = buf.subarray(0, cut).toString("utf8");
2695
+ return { text: head, truncated: true, totalBytes, shownBytes: cut };
2696
+ }
2697
+ function buildTruncationHint(name, args2, total, shown) {
2698
+ const base = `(showing first ${shown} of ${total} bytes;`;
2699
+ if (name === "sftp-read") {
2700
+ const off = Number(args2.offset) || 0;
2701
+ const next = off + shown;
2702
+ return `${base} pass { offset: ${next}, length: ${RESPONSE_MAX_BYTES} } to read more)`;
2703
+ }
2704
+ if (name === "docker-logs") {
2705
+ return `${base} use \`tail\`, \`since\`, or \`grep\` to narrow the window)`;
2706
+ }
2707
+ if (name === "ssh-execute") {
2708
+ return `${base} pipe through head/tail/grep server-side, or use sftp-read with offset for huge logs)`;
2709
+ }
2710
+ return `${base} narrow your query, paginate, or grep server-side to read the rest)`;
2711
+ }
2712
+ var TRANSIENT_RE = /(ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|connection\s+refused|connection\s+reset|getaddrinfo|connect\s+ETIMEDOUT|socket\s+hang\s*up|read\s+ECONNRESET|All configured authentication methods failed)/i;
2713
+ function isTransientSshError(stderr, exitCode) {
2714
+ if (exitCode !== -1) return false;
2715
+ if (!stderr) return false;
2716
+ return TRANSIENT_RE.test(stderr);
2717
+ }
2718
+ function postprocessResult(result, meta) {
2719
+ if (!result.content?.length) return result;
2720
+ const block = result.content[0];
2721
+ let text = String(block.text ?? "");
2722
+ const trunc = truncateForLLM(text, RESPONSE_MAX_BYTES);
2723
+ if (trunc.truncated) {
2724
+ text = trunc.text + "\n\n... " + buildTruncationHint(meta.toolName, meta.args, trunc.totalBytes, trunc.shownBytes);
2725
+ }
2726
+ if (!NO_FOOTER_TOOLS.has(meta.toolName)) {
2727
+ const tookMs = Date.now() - meta.startedAtMs;
2728
+ const tookStr = tookMs < 1e3 ? `${tookMs}ms` : `${(tookMs / 1e3).toFixed(1)}s`;
2729
+ const sizeStr = trunc.totalBytes >= 1024 ? `${(trunc.totalBytes / 1024).toFixed(1)} KB` : `${trunc.totalBytes} B`;
2730
+ const parts = [`took ${tookStr}`, sizeStr];
2731
+ if (meta.serverIdLabel) parts.push(`server: ${meta.serverIdLabel}`);
2732
+ if (meta.cached) parts.push("cached");
2733
+ text = `${text}
2734
+
2735
+ [${parts.join(", ")}]`;
2736
+ }
2737
+ return { ...result, content: [{ ...block, text }] };
2738
+ }
2739
+ function buildPipelineScript(commands, shell, marker, stopOnError) {
2740
+ if (shell === "powershell") {
2741
+ const lines = ["$ErrorActionPreference='Continue'", "$ProgressPreference='SilentlyContinue'"];
2742
+ for (let i = 0; i < commands.length; i++) {
2743
+ lines.push(`Write-Output '${marker} ${i} begin'`);
2744
+ lines.push(`& { ${commands[i]} } 2>&1`);
2745
+ lines.push(`$__c = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }`);
2746
+ lines.push(`Write-Output ('${marker} ' + ${i} + ' end exit=' + $__c)`);
2747
+ if (stopOnError) lines.push(`if ($__c -ne 0) { exit $__c }`);
2748
+ }
2749
+ return buildPowerShellEncodedCommand(lines.join("; "), []);
2750
+ }
2751
+ const sh = [];
2752
+ for (let i = 0; i < commands.length; i++) {
2753
+ sh.push(`echo "${marker} ${i} begin"`);
2754
+ sh.push(`{ ${commands[i]}; } 2>&1`);
2755
+ sh.push(`__c=$?`);
2756
+ sh.push(`echo "${marker} ${i} end exit=$__c"`);
2757
+ if (stopOnError) sh.push(`if [ "$__c" -ne 0 ]; then exit "$__c"; fi`);
2758
+ }
2759
+ return `bash -c ${posixQuote(sh.join("; "))}`;
2760
+ }
2761
+ function parsePipelineOutput(stdout, commands, marker) {
2762
+ const segments = [];
2763
+ for (let i = 0; i < commands.length; i++) {
2764
+ const beginRe = new RegExp(`^${escapeRegex(marker)}\\s+${i}\\s+begin\\s*$`, "m");
2765
+ const endRe = new RegExp(`^${escapeRegex(marker)}\\s+${i}\\s+end\\s+exit=(-?\\d+)\\s*$`, "m");
2766
+ const beginMatch = beginRe.exec(stdout);
2767
+ const endMatch = endRe.exec(stdout);
2768
+ if (!beginMatch || !endMatch) {
2769
+ segments.push({ index: i, command: commands[i], output: "(no output captured \u2014 pipeline aborted before this step?)", exitCode: -1 });
2770
+ continue;
2771
+ }
2772
+ const start = beginMatch.index + beginMatch[0].length + 1;
2773
+ const end = endMatch.index;
2774
+ const body = stdout.slice(start, end).replace(/\n$/, "");
2775
+ const code = parseInt(endMatch[1], 10);
2776
+ segments.push({ index: i, command: commands[i], output: body, exitCode: Number.isFinite(code) ? code : -1 });
2777
+ }
2778
+ return segments;
2779
+ }
2780
+ function escapeRegex(s) {
2781
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2782
+ }
2783
+ function renderPipelineSegments(segments, overall, format) {
2784
+ const sections = [];
2785
+ for (const seg of segments) {
2786
+ sections.push(`>>> [${seg.index + 1}/${segments.length}] ${seg.command} (exit ${seg.exitCode})`);
2787
+ let body = seg.output;
2788
+ if (format === "ndjson") {
2789
+ const parsed = tryParseNdjsonFromCommand(seg.command, body);
2790
+ if (parsed) body = parsed;
2791
+ }
2792
+ sections.push(body || "(no output)");
2793
+ }
2794
+ if (overall.stderr) sections.push(`--- aggregate stderr ---
2795
+ ${overall.stderr.trimEnd()}`);
2796
+ return sections.join("\n");
2797
+ }
2798
+ function renderRepeatResult(iters, mode, format) {
2799
+ const formatBody = (s) => {
2800
+ if (format !== "ndjson") return s;
2801
+ return tryParseNdjsonFromCommand("", s) || s;
2802
+ };
2803
+ if (mode === "last") {
2804
+ const last = iters[iters.length - 1];
2805
+ return `--- iteration ${last.index + 1}/${iters.length} (exit ${last.result.exitCode}, ${last.durationMs}ms) ---
2806
+ ${formatBody(last.result.stdout) || "(empty)"}${last.result.stderr ? `
2807
+ [stderr] ${last.result.stderr.trimEnd()}` : ""}`;
2808
+ }
2809
+ if (mode === "diff") {
2810
+ const sections2 = [`(repeat: ${iters.length} runs, diff mode \u2014 only differences vs. previous run shown)`];
2811
+ let prev = "";
2812
+ for (let i = 0; i < iters.length; i++) {
2813
+ const cur = iters[i].result.stdout;
2814
+ if (i === 0) {
2815
+ sections2.push(`--- iteration 1 (initial, ${iters[i].durationMs}ms) ---
2816
+ ${formatBody(cur) || "(empty)"}`);
2817
+ } else {
2818
+ const changes = diffLines(prev, cur);
2819
+ const header = `--- iteration ${i + 1} (${iters[i].durationMs}ms, +${changes.added.length} -${changes.removed.length}) ---`;
2820
+ const block = [header];
2821
+ if (changes.added.length === 0 && changes.removed.length === 0) {
2822
+ block.push("(no changes)");
2823
+ } else {
2824
+ for (const line of changes.added) block.push(`+ ${line}`);
2825
+ for (const line of changes.removed) block.push(`- ${line}`);
2826
+ }
2827
+ sections2.push(block.join("\n"));
2828
+ }
2829
+ prev = cur;
2830
+ }
2831
+ return sections2.join("\n");
2832
+ }
2833
+ const sections = [`(repeat: ${iters.length} runs, all output)`];
2834
+ for (const it of iters) {
2835
+ sections.push(`--- iteration ${it.index + 1}/${iters.length} (exit ${it.result.exitCode}, ${it.durationMs}ms) ---`);
2836
+ sections.push(formatBody(it.result.stdout) || "(empty)");
2837
+ if (it.result.stderr) sections.push(`[stderr] ${it.result.stderr.trimEnd()}`);
2838
+ }
2839
+ return sections.join("\n");
2840
+ }
2841
+ function diffLines(a, b) {
2842
+ const aSet = new Set(a.split("\n"));
2843
+ const bSet = new Set(b.split("\n"));
2844
+ const added = [];
2845
+ const removed = [];
2846
+ for (const line of bSet) if (!aSet.has(line) && line) added.push(line);
2847
+ for (const line of aSet) if (!bSet.has(line) && line) removed.push(line);
2848
+ return { added, removed };
2849
+ }
2850
+ function tryParseNdjsonFromCommand(command, stdout) {
2851
+ if (!stdout.trim()) return null;
2852
+ const c = command.toLowerCase();
2853
+ const looksLike = (re) => re.test(c);
2854
+ if (looksLike(/\bdf\b/) || /^Filesystem\s+\S+\s+Used\s+/m.test(stdout)) {
2855
+ return parseDf(stdout);
2856
+ }
2857
+ if (looksLike(/\bfree\b/) || /^\s*total\s+used\s+free/m.test(stdout)) {
2858
+ return parseFree(stdout);
2859
+ }
2860
+ if (looksLike(/\bps\b/) || /^USER\s+PID\s+%CPU\s+%MEM/m.test(stdout)) {
2861
+ return parsePsAux(stdout);
2862
+ }
2863
+ if (looksLike(/\bss\b/) || /^State\s+Recv-Q\s+Send-Q/m.test(stdout)) {
2864
+ return parseSs(stdout);
2865
+ }
2866
+ if (looksLike(/\blsblk\b/) || /^NAME\s+MAJ:MIN/m.test(stdout)) {
2867
+ return parseLsblk(stdout);
2868
+ }
2869
+ return null;
2870
+ }
2871
+ function splitCols(line, max) {
2872
+ const parts = line.trim().split(/\s+/);
2873
+ if (max && parts.length > max) {
2874
+ return [...parts.slice(0, max - 1), parts.slice(max - 1).join(" ")];
2875
+ }
2876
+ return parts;
2877
+ }
2878
+ function parseDf(s) {
2879
+ const lines = s.split("\n").filter(Boolean);
2880
+ const header = lines[0];
2881
+ if (!header || !/Filesystem/i.test(header)) return null;
2882
+ const cols = header.trim().split(/\s+/);
2883
+ const out = [];
2884
+ for (const ln of lines.slice(1)) {
2885
+ const parts = splitCols(ln, cols.length);
2886
+ if (parts.length < 4) continue;
2887
+ const obj = {};
2888
+ for (let i = 0; i < cols.length; i++) obj[cols[i]] = parts[i] || "";
2889
+ out.push(JSON.stringify(obj));
2890
+ }
2891
+ return out.length ? out.join("\n") : null;
2892
+ }
2893
+ function parseFree(s) {
2894
+ const lines = s.split("\n").filter(Boolean);
2895
+ const out = [];
2896
+ for (const ln of lines) {
2897
+ if (/^\s*total\s+used\s+free/i.test(ln)) continue;
2898
+ const m = ln.match(/^(\S+):?\s+(.+)$/);
2899
+ if (!m) continue;
2900
+ const [type, rest] = [m[1], m[2]];
2901
+ const cols = rest.trim().split(/\s+/);
2902
+ out.push(JSON.stringify({ type: type.replace(/:$/, ""), values: cols.map(Number) }));
2903
+ }
2904
+ return out.length ? out.join("\n") : null;
2905
+ }
2906
+ function parsePsAux(s) {
2907
+ const lines = s.split("\n").filter(Boolean);
2908
+ if (!lines[0] || !/USER\s+PID/i.test(lines[0])) return null;
2909
+ const headers = lines[0].trim().split(/\s+/);
2910
+ const out = [];
2911
+ for (const ln of lines.slice(1)) {
2912
+ const parts = splitCols(ln, headers.length);
2913
+ if (parts.length < headers.length) continue;
2914
+ const obj = {};
2915
+ for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
2916
+ out.push(JSON.stringify(obj));
2917
+ }
2918
+ return out.length ? out.join("\n") : null;
2919
+ }
2920
+ function parseSs(s) {
2921
+ const lines = s.split("\n").filter(Boolean);
2922
+ if (!lines[0] || !/(State|Netid)/i.test(lines[0])) return null;
2923
+ const headers = lines[0].trim().split(/\s+/);
2924
+ const out = [];
2925
+ for (const ln of lines.slice(1)) {
2926
+ const parts = splitCols(ln, headers.length);
2927
+ const obj = {};
2928
+ for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
2929
+ out.push(JSON.stringify(obj));
2930
+ }
2931
+ return out.length ? out.join("\n") : null;
2932
+ }
2933
+ function parseLsblk(s) {
2934
+ const lines = s.split("\n").filter(Boolean);
2935
+ if (!lines[0] || !/NAME\s+MAJ:MIN/i.test(lines[0])) return null;
2936
+ const headers = lines[0].trim().split(/\s+/);
2937
+ const out = [];
2938
+ for (const ln of lines.slice(1)) {
2939
+ const parts = splitCols(ln, headers.length);
2940
+ const obj = {};
2941
+ for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
2942
+ out.push(JSON.stringify(obj));
2943
+ }
2944
+ return out.length ? out.join("\n") : null;
2945
+ }
2946
+ async function resolveServerLabel(serverId, serverIds) {
2947
+ if (Array.isArray(serverIds) && serverIds.length > 0) return `${serverIds.length} servers`;
2948
+ if (!serverId) return void 0;
2949
+ const cached = KNOWN_SERVER_NAMES.get(serverId);
2950
+ if (cached) return cached.name;
2951
+ try {
2952
+ const { data } = await supabase.from("ssh_server").select("id, name").eq("id", serverId).maybeSingle();
2953
+ if (data?.name) {
2954
+ KNOWN_SERVER_NAMES.set(serverId, { id: serverId, name: data.name });
2955
+ return data.name;
2956
+ }
2957
+ } catch {
2958
+ }
2959
+ return serverId.slice(0, 8);
2960
+ }
2467
2961
  async function sftpWrite(opts, filePath, input, proxy, meta) {
2468
2962
  const safe = sanitizePath(filePath);
2469
2963
  assertWritablePath(safe);
@@ -2664,6 +3158,92 @@ async function sftpDelete(opts, filePath, proxy, options) {
2664
3158
  return `Error: ${e.message}`;
2665
3159
  }
2666
3160
  }
3161
+ async function sftpCopy(src, dst) {
3162
+ const startedAt = Date.now();
3163
+ if (src.kind === "r2" && dst.kind === "r2") {
3164
+ const { size } = await r2CopyObject(src.bucket, src.key, dst.bucket, dst.key);
3165
+ return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2-server-side" };
3166
+ }
3167
+ if (src.kind === "r2" && dst.kind === "ssh") {
3168
+ const safe = sanitizePath(dst.path);
3169
+ assertWritablePath(safe);
3170
+ const { stream, size } = await r2GetObjectStream(src.bucket, src.key);
3171
+ const { client, cleanup } = await connectSshClient(dst.opts, dst.proxy, 6e4);
3172
+ try {
3173
+ await new Promise((resolve, reject) => {
3174
+ client.sftp((err, sftp) => {
3175
+ if (err) return reject(err);
3176
+ const ws = sftp.createWriteStream(safe);
3177
+ ws.on("close", () => resolve());
3178
+ ws.on("error", reject);
3179
+ stream.on("error", reject);
3180
+ stream.pipe(ws);
3181
+ });
3182
+ });
3183
+ } finally {
3184
+ cleanup();
3185
+ }
3186
+ return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2\u2192ssh-stream" };
3187
+ }
3188
+ if (src.kind === "ssh" && dst.kind === "r2") {
3189
+ const safeSrc = sanitizePath(src.path);
3190
+ const { client, cleanup } = await connectSshClient(src.opts, src.proxy, 6e4);
3191
+ try {
3192
+ const { stream, size } = await new Promise((resolve, reject) => {
3193
+ client.sftp((err, sftp) => {
3194
+ if (err) return reject(err);
3195
+ sftp.stat(safeSrc, (statErr, stats) => {
3196
+ if (statErr) return reject(statErr);
3197
+ const s = stats.size || 0;
3198
+ const rs = sftp.createReadStream(safeSrc);
3199
+ resolve({ stream: rs, size: s });
3200
+ });
3201
+ });
3202
+ });
3203
+ if (size > R2_MULTIPART_THRESHOLD) {
3204
+ cleanup();
3205
+ throw new Error(`Source object is ${formatBytes(size)} which exceeds R2 single-PUT cap (${formatBytes(R2_MULTIPART_THRESHOLD)}). For now: download via sftp-write { sourceRemote, target: local } first, then upload to R2 with sourcePath.`);
3206
+ }
3207
+ await r2PutObject(dst.bucket, dst.key, stream, size);
3208
+ return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192r2-stream" };
3209
+ } finally {
3210
+ cleanup();
3211
+ }
3212
+ }
3213
+ if (src.kind === "ssh" && dst.kind === "ssh") {
3214
+ const safeSrc = sanitizePath(src.path);
3215
+ const safeDst = sanitizePath(dst.path);
3216
+ assertWritablePath(safeDst);
3217
+ const srcConn = await connectSshClient(src.opts, src.proxy, 6e4);
3218
+ let dstConn;
3219
+ try {
3220
+ dstConn = await connectSshClient(dst.opts, dst.proxy, 6e4);
3221
+ const bytes = await new Promise((resolve, reject) => {
3222
+ srcConn.client.sftp((err, srcSftp) => {
3223
+ if (err) return reject(err);
3224
+ dstConn.client.sftp((err2, dstSftp) => {
3225
+ if (err2) return reject(err2);
3226
+ srcSftp.stat(safeSrc, (statErr, stats) => {
3227
+ if (statErr) return reject(statErr);
3228
+ const size = stats.size || 0;
3229
+ const rs = srcSftp.createReadStream(safeSrc);
3230
+ const ws = dstSftp.createWriteStream(safeDst);
3231
+ ws.on("close", () => resolve(size));
3232
+ ws.on("error", reject);
3233
+ rs.on("error", reject);
3234
+ rs.pipe(ws);
3235
+ });
3236
+ });
3237
+ });
3238
+ });
3239
+ return { bytes, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192ssh-stream" };
3240
+ } finally {
3241
+ srcConn.cleanup();
3242
+ dstConn?.cleanup();
3243
+ }
3244
+ }
3245
+ throw new Error("Unsupported copy combination");
3246
+ }
2667
3247
  var BLOCKED_COMMANDS = [
2668
3248
  "rm -rf /",
2669
3249
  "rm -fr /",
@@ -2848,33 +3428,41 @@ async function mijnhostFetch(path, options = {}) {
2848
3428
  var TOOLS = [
2849
3429
  {
2850
3430
  name: "list-servers",
2851
- description: "List all SSH servers you have access to. Returns id, name, hostname, tags, and os_type per server. Pass `includeStats: true` to also probe each server in parallel for container count, disk-free, and uptime (skips unreachable hosts gracefully).",
3431
+ description: 'List all SSH servers you have access to. Returns id, name, hostname, tags, and os_type per server. Pass `includeStats: true` to also probe each server in parallel for container count, disk-free, and uptime (skips unreachable hosts gracefully). Cached for 60s \u2014 pass `noCache: true` to force a refresh. Adds an "auto-context" footer with one-line tags inferred from server name/tags so the LLM knows which host runs Trigger / Supabase / proxy / etc. (disable with `context: false`).',
2852
3432
  inputSchema: {
2853
3433
  type: "object",
2854
3434
  properties: {
2855
3435
  includeStats: { type: "boolean", description: "When true, probe each server (in parallel, with timeout) for container count, disk-free, and uptime." },
2856
3436
  statsParallelism: { type: "number", description: "Max parallel probes when includeStats is true (default 8, cap 20)." },
2857
- statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." }
3437
+ statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." },
3438
+ context: { type: "boolean", description: "Include auto-context footer with name/tag-based hints (default true). Pass false to suppress." },
3439
+ noCache: { type: "boolean", description: "Bypass the 60s in-memory cache." }
2858
3440
  }
2859
3441
  }
2860
3442
  },
2861
3443
  {
2862
3444
  name: "ssh-execute",
2863
- description: 'Execute a command on a remote server via SSH. OS-aware: bash on linux, `powershell -EncodedCommand` (UTF-16LE base64) on windows, so $, #, quotes, spaces inside `args` are never re-interpreted by a shell. Some dangerous commands are blocked. Use `list-servers` first to see each server\'s os_type.\nTwo ways to invoke (use `args` for anything with passwords or special chars):\n- Quick: `command` only, e.g. `command: "df -h"`.\n- Safe: `command` + `args[]`, e.g. `command: "mysql"`, `args: ["-u","root","-p$tr@nge#pwd","-e","SELECT 1"]`.\n`stdin` pipes data into the remote process (queries, scripts, secrets) without putting it on the command line.\n`cwd` sets the working directory (auto `cd` on Linux / `Set-Location` on Windows).\nFan-out: pass `serverIds: [id1, id2, ...]` (instead of `serverId`) to run the same command across multiple servers in parallel. Each server\'s output is grouped under its own `=== name (os, exit N) ===` header.',
3445
+ description: 'Execute one or more commands on a remote server via SSH. OS-aware: bash on linux, `powershell -EncodedCommand` (UTF-16LE base64) on windows, so $, #, quotes, spaces inside `args` are never re-interpreted by a shell. Some dangerous commands are blocked. Use `list-servers` first to see each server\'s os_type.\n\nThree modes (compose them in one call):\n- Single: `command` (+ optional `args[]`). Quick or safe.\n- Pipeline: `pipeline: ["cmd1","cmd2","cmd3"]` runs all commands sequentially in one SSH session and returns segmented per-command output (each with its own exit code). Default continues on error; pass `pipelineStopOnError: true` to abort.\n- Repeat: `repeat: N, intervalSeconds: S, output: "all"|"diff"|"last"`. Diff mode shows only added/removed lines vs. the previous run \u2014 perfect for "watch this metric for 5 min".\n\n`stdin` pipes data into the remote process (queries, scripts, secrets) without putting it on the command line. `cwd` sets the working directory (auto `cd` on Linux / `Set-Location` on Windows).\n\nIdle timeout: defaults to 120s \u2014 if the remote produces no stdout/stderr for that long the call returns with a clear timeout message. Pass `idleTimeoutSeconds: 0` to disable, or e.g. `300` for slow workloads.\n\nFan-out: pass `serverIds: [id1, id2, ...]` (instead of `serverId`) to run across multiple servers in parallel.\n\nAuto-features: outputs >8 KB are auto-truncated with a continuation hint; transient SSH failures (ECONNRESET/ETIMEDOUT/etc) get one automatic retry. Set `format: "ndjson"` to parse common Linux outputs (df / free / ps aux / ss / lsblk) into newline-delimited JSON instead of whitespace-padded text.',
2864
3446
  inputSchema: {
2865
3447
  type: "object",
2866
3448
  properties: {
2867
3449
  serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
2868
3450
  serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to run the command on in parallel. Mutually exclusive with serverId." },
2869
- command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required.' },
3451
+ command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required unless `pipeline[]` is set.' },
2870
3452
  args: { type: "array", items: { type: "string" }, description: "Optional argv list. When provided, each entry is safely quoted/encoded for the target OS." },
3453
+ pipeline: { type: "array", items: { type: "string" }, description: "Run multiple shell commands sequentially in ONE SSH session, with per-command exit codes and segmented output. Saves N round-trips for setup\u2192test\u2192cleanup workflows." },
3454
+ pipelineStopOnError: { type: "boolean", description: "When pipeline is set, abort the chain on the first non-zero exit (default false: keep going so all outputs reach the LLM)." },
3455
+ repeat: { type: "number", description: 'Re-run the command/pipeline N times (default 1, max 60). Combine with intervalSeconds + output to "watch" a metric.' },
3456
+ intervalSeconds: { type: "number", description: "When repeat>1, seconds to wait between iterations (default 0, max 600)." },
3457
+ output: { type: "string", enum: ["all", "diff", "last"], description: "When repeat>1: how to render iterations. `all` = full output per run; `diff` = only added/removed lines vs. previous run; `last` = only the final iteration." },
3458
+ format: { type: "string", enum: ["text", "ndjson"], description: "Output format. `ndjson` parses common Linux outputs (df, free, ps aux, ss, lsblk) into newline-delimited JSON." },
2871
3459
  stdin: { type: "string", description: "Optional data piped to the remote process stdin." },
2872
3460
  cwd: { type: "string", description: "Working directory on the remote (cd <cwd> on Linux, Set-Location on Windows)." },
2873
3461
  shell: { type: "string", enum: ["auto", "bash", "powershell"], description: `Override shell selection (default "auto" uses the server's os_type).` },
2874
3462
  parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
2875
- timeout: { type: "number", description: "Per-server timeout in milliseconds (default: 60000)" }
2876
- },
2877
- required: ["command"]
3463
+ timeout: { type: "number", description: "Wall timeout in milliseconds (default: 60000). Usually idleTimeoutSeconds is what you want instead." },
3464
+ idleTimeoutSeconds: { type: "number", description: 'Abort if no output for N seconds (default 120). Pass 0 to disable. Useful when the wall budget is hard to predict but silence usually means "stuck".' }
3465
+ }
2878
3466
  }
2879
3467
  },
2880
3468
  {
@@ -2913,65 +3501,73 @@ var TOOLS = [
2913
3501
  },
2914
3502
  {
2915
3503
  name: "sftp-write",
2916
- description: "Write a file to remote storage. Two backends (provide exactly one of `serverId` or `bucket`) and two source modes (provide exactly one of `content` or `sourcePath`):\n\nBackends:\n- `serverId`: SSH server via SFTP. Protected system paths blocked. Optional `mode` sets POSIX permission bits (e.g. 0o755 for executables) and `mtime` sets the modification time.\n- `bucket`: Cloudflare R2 bucket (S3-compatible). Single PUT for files <4.5 GB, automatic multipart upload (100 MB parts) for larger files up to ~5 TB. `mode`/`mtime` are ignored on R2.\n\nSources:\n- `content` (string): inline UTF-8 text. Practical max ~1 MB.\n- `sourcePath` (absolute local path): streams the local file. SSH uses ssh2 fastPut (64 parallel pipelined writes); R2 uses streamed PUT or multipart depending on size. Both handle GB-scale files. Only usable when the MCP runs locally.",
3504
+ description: "Write OR copy a file in remote storage. Two target backends (`serverId` or `bucket`) and three source modes:\n\nSources:\n- `content` (string): inline UTF-8 text (\u22481 MB max).\n- `sourcePath` (absolute local path): streams a local file. SSH uses ssh2 fastPut; R2 uses streamed PUT (or automatic multipart for >4.5 GB). Handles GB-scale.\n- `sourceRemote: { serverId|bucket, path }`: COPY mode \u2014 pulls from one remote and writes to another in a single operation, no local detour. Supports all four directions: ssh\u2194ssh, ssh\u2194r2, r2\u2194ssh, r2\u2194r2 (R2\u2194R2 uses server-side CopyObject = zero data egress).\n\nTargets:\n- `serverId`: SSH server via SFTP. Protected system paths blocked. Optional `mode` (POSIX bits e.g. 0o755) and `mtime` (modification time) \u2014 SSH only.\n- `bucket`: Cloudflare R2 bucket. `mode`/`mtime` are ignored on R2.",
2917
3505
  inputSchema: {
2918
3506
  type: "object",
2919
3507
  properties: {
2920
- serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
2921
- bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
2922
- path: { type: "string", description: "Remote file path (SSH) or object key (R2)" },
2923
- content: { type: "string", description: "Inline UTF-8 file content (mutually exclusive with sourcePath)" },
2924
- sourcePath: { type: "string", description: "Absolute local file path to upload (mutually exclusive with content). Use for files >1 MB or any binary." },
2925
- mode: { oneOf: [{ type: "number" }, { type: "string" }], description: 'POSIX file permission bits (SSH only). Number (0o755 / 493) or octal string ("755" / "0o755"). Default 0o644.' },
2926
- mtime: { oneOf: [{ type: "number" }, { type: "string" }], description: "File modification time (SSH only). ms-since-epoch number or ISO-8601 date string." }
3508
+ serverId: { type: "string", description: "TARGET: UUID of the SSH server (mutually exclusive with bucket)" },
3509
+ bucket: { type: "string", description: "TARGET: Cloudflare R2 bucket name (mutually exclusive with serverId)" },
3510
+ path: { type: "string", description: "TARGET path (SSH file path or R2 object key)" },
3511
+ content: { type: "string", description: "Source: inline UTF-8 file content (mutually exclusive with sourcePath / sourceRemote)" },
3512
+ sourcePath: { type: "string", description: "Source: absolute local file path (mutually exclusive with content / sourceRemote). Use for >1 MB or binary." },
3513
+ sourceRemote: { type: "object", description: "Source: copy from another remote. Shape: { serverId?: string, bucket?: string, path: string }. Picks the streaming strategy (ssh\u2192ssh, r2\u2192ssh, ssh\u2192r2, r2\u2192r2 server-side).", properties: { serverId: { type: "string" }, bucket: { type: "string" }, path: { type: "string" } } },
3514
+ mode: { oneOf: [{ type: "number" }, { type: "string" }], description: 'POSIX file permission bits (SSH target only). Number (0o755 / 493) or octal string ("755" / "0o755"). Default 0o644.' },
3515
+ mtime: { oneOf: [{ type: "number" }, { type: "string" }], description: "File modification time (SSH target only). ms-since-epoch number or ISO-8601 date string." }
2927
3516
  },
2928
3517
  required: ["path"]
2929
3518
  }
2930
3519
  },
2931
3520
  {
2932
3521
  name: "sftp-delete",
2933
- description: "Delete a file (or directory tree) from remote storage. Two backends (provide exactly one of `serverId` or `bucket`):\n- `serverId`: delete a file or empty directory on an SSH server via SFTP. Protected system paths blocked. With `recursive: true` walks the tree depth-first via SFTP and removes files + empty dirs from the bottom up (works on Windows OpenSSH too \u2014 no shell tricks).\n- `bucket`: delete an object from a Cloudflare R2 bucket. `path` is the object key. With `recursive: true`, treats `path` as a key prefix and batch-deletes every object beneath it (R2 caps at 1000 keys per call; we loop pages automatically).",
3522
+ description: 'Delete a file (or directory tree) from remote storage. Two backends (provide exactly one of `serverId` or `bucket`):\n- `serverId`: delete a file or empty directory on an SSH server via SFTP. Protected system paths blocked. With `recursive: true` walks the tree depth-first via SFTP and removes files + empty dirs from the bottom up (works on Windows OpenSSH too \u2014 no shell tricks).\n- `bucket`: delete an object from a Cloudflare R2 bucket. `path` is the object key. With `recursive: true`, treats `path` as a key prefix and batch-deletes every object beneath it.\n\nSafety nets:\n- `dryRun: true` \u2192 returns "Would delete N items (size)" with a sample preview, no actual deletion.\n- `confirmAbove: N` + `confirmCount: <exact>` \u2192 refuses recursive deletes touching more than N items unless the caller passes `confirmCount` matching the actual count.',
2934
3523
  inputSchema: {
2935
3524
  type: "object",
2936
3525
  properties: {
2937
3526
  serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
2938
3527
  bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
2939
3528
  path: { type: "string", description: "File path / object key (or directory / key prefix when recursive)" },
2940
- recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." }
3529
+ recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." },
3530
+ dryRun: { type: "boolean", description: "Preview-only mode: returns what would be deleted (count, total size, sample paths) without touching anything." },
3531
+ confirmAbove: { type: "number", description: "Refuse recursive deletes that touch more than N items unless `confirmCount` matches the actual count." },
3532
+ confirmCount: { type: "number", description: "When confirmAbove gates a delete, pass the exact item count from the dry-run output to confirm intent." }
2941
3533
  },
2942
3534
  required: ["path"]
2943
3535
  }
2944
3536
  },
2945
3537
  {
2946
3538
  name: "docker-list",
2947
- description: "List Docker containers on a remote server. Sorted by compose project then name, with a HEALTH column (healthy / unhealthy / starting / no-check). Filter by `nameFilter` (string OR array of substring/glob entries \u2014 passes if ANY entry matches), restrict to compose containers with `composeOnly`, or group output per project with `groupByProject`.",
3539
+ description: 'List Docker containers on one or more remote servers. Sorted by compose project then name, with a HEALTH column (healthy / unhealthy / starting / no-check). Filter by `nameFilter` (string OR array of substring/glob entries \u2014 passes if ANY entry matches), restrict to compose containers with `composeOnly`, or group output per project with `groupByProject`.\n\nFan-out: pass `serverIds: [...]` (instead of `serverId`) to query multiple servers in parallel \u2014 perfect for "show all unhealthy containers across the fleet". Per-server sections are clearly headed.\n\nCached for 30s \u2014 pass `noCache: true` to bypass.',
2948
3540
  inputSchema: {
2949
3541
  type: "object",
2950
3542
  properties: {
2951
- serverId: { type: "string", description: "UUID of the SSH server" },
2952
- format: { type: "string", enum: ["table", "json"], description: "Output format: human table (default) or NDJSON (one JSON object per line, includes labels)." },
3543
+ serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
3544
+ serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to query in parallel. Mutually exclusive with serverId." },
3545
+ format: { type: "string", enum: ["table", "json"], description: 'Output format: human table (default) or NDJSON. JSON adds a "Server" field per row in fan-out mode.' },
2953
3546
  composeOnly: { type: "boolean", description: "Only show containers that have a docker-compose project label." },
2954
3547
  nameFilter: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Filter by container name. Single entry or array; each entry is glob (* or ?) or substring. A row passes when ANY entry matches." },
2955
- groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header." }
2956
- },
2957
- required: ["serverId"]
3548
+ groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header (single-server mode only)." },
3549
+ parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
3550
+ noCache: { type: "boolean", description: "Bypass the 30s in-memory cache." }
3551
+ }
2958
3552
  }
2959
3553
  },
2960
3554
  {
2961
3555
  name: "docker-logs",
2962
- description: 'Get logs from one or more Docker containers. `containerName` accepts a string OR an array \u2014 multi-container output is line-prefixed with `[name]` so you can read related services together (e.g. `["refront-rest-1", "refront-kong-1", "refront-db-1"]`). Supports time-window (`since`), server-side `grep`, line-count (`tail`), and real-time follow (`followSeconds`). Always merges stderr into stdout.',
3556
+ description: 'Get logs from one or more Docker containers, optionally across multiple servers. `containerName` accepts a string OR array \u2014 multi-container output is line-prefixed with `[name]`. Supports time-window (`since`), server-side `grep`, line-count (`tail`), and real-time follow (`followSeconds`). Always merges stderr into stdout. Outputs >8 KB get a continuation hint.\n\nFan-out: pass `serverIds: [...]` to tail the same container(s) across multiple servers in one call (e.g. all kong instances). Per-server output is grouped under headers.\n\nFuzzy hint: if a container name typo is detected, the error includes "Did you mean: ..." suggestions from the docker-list cache.',
2963
3557
  inputSchema: {
2964
3558
  type: "object",
2965
3559
  properties: {
2966
- serverId: { type: "string", description: "UUID of the SSH server" },
3560
+ serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
3561
+ serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to tail in parallel. Mutually exclusive with serverId." },
2967
3562
  containerName: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Single container name/ID or an array for multi-container tail. Multi mode prefixes each line with [name]." },
2968
- tail: { type: "number", description: "Number of recent log lines per container (default 100). Alias: `lines`." },
3563
+ tail: { type: "number", description: "Number of recent log lines per container (default 100, default 0 in follow mode)." },
2969
3564
  lines: { type: "number", description: "Deprecated alias for `tail`. Prefer `tail`." },
2970
3565
  since: { type: "string", description: 'Time window, e.g. "10m", "2h", "24h", or an absolute "2026-05-09T10:00:00".' },
2971
3566
  grep: { type: "string", description: "Case-insensitive regex/literal filter applied server-side (saves tokens for noisy containers)." },
2972
- followSeconds: { type: "number", description: "When >0, stream new log lines for N seconds via `docker logs -f`, then exit (max 300). Multi-container follow runs all streams in parallel." }
3567
+ followSeconds: { type: "number", description: "When >0, stream new log lines for N seconds via `docker logs -f`, then exit (max 300). Multi-container follow runs all streams in parallel." },
3568
+ parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." }
2973
3569
  },
2974
- required: ["serverId", "containerName"]
3570
+ required: ["containerName"]
2975
3571
  }
2976
3572
  },
2977
3573
  {
@@ -3009,6 +3605,24 @@ var TOOLS = [
3009
3605
  required: ["serverId", "projectPath", "action"]
3010
3606
  }
3011
3607
  },
3608
+ {
3609
+ name: "wait-for",
3610
+ description: 'Poll until a condition holds (or timeout). Replaces 30+ ad-hoc loops the LLM would otherwise build itself. Four condition types:\n- `url`: `target: "https://..."`, `until: 200` (HTTP status, default 200). 10s per probe.\n- `container-health`: `serverId`, `target: "container-name"`, `until: "healthy" | "running" | "exited"` (default "healthy"). Uses `docker inspect`.\n- `file-exists`: `serverId`, `target: "/path/to/file"` \u2014 SSH stat check.\n- `sftp-exists`: same as file-exists but with `bucket: "name"` instead of serverId for R2.\n\nReturns a per-attempt trail so you can see how long it took to converge.',
3611
+ inputSchema: {
3612
+ type: "object",
3613
+ properties: {
3614
+ type: { type: "string", enum: ["url", "container-health", "file-exists", "sftp-exists"], description: "Condition type to wait on." },
3615
+ target: { type: "string", description: "Target identifier: URL, container name, file path, or R2 key (depends on type)." },
3616
+ until: { description: 'Expected end-state (depends on type): for url an HTTP status (default 200), for container-health a state string (default "healthy"). Omit for file-exists/sftp-exists.' },
3617
+ serverId: { type: "string", description: "SSH server UUID (required for container-health and file-exists)." },
3618
+ bucket: { type: "string", description: "R2 bucket name (used with sftp-exists when checking an R2 object)." },
3619
+ path: { type: "string", description: "Alias for `target` for file-exists/sftp-exists." },
3620
+ intervalSeconds: { type: "number", description: "Poll interval in seconds (default 3, max 60)." },
3621
+ timeout: { type: "number", description: "Total deadline in seconds (default 120, max 1800)." }
3622
+ },
3623
+ required: ["type", "target"]
3624
+ }
3625
+ },
3012
3626
  {
3013
3627
  name: "db-discover",
3014
3628
  description: "Scan /var/www on a server for web applications (WordPress, PrestaShop, Laravel, .env) and list their database credentials. Use this first to find available sites before running other db-* tools.",
@@ -3047,15 +3661,20 @@ var TOOLS = [
3047
3661
  },
3048
3662
  {
3049
3663
  name: "db-query",
3050
- description: "Execute a SQL query on a site database. Credentials are auto-discovered from site config files. Destructive operations (DROP, TRUNCATE) are blocked.",
3664
+ description: 'Execute a SQL query against any database. Two routing modes:\n- MySQL via `/var/www` autodiscover (default): pass `sitePath` (and `engine: "mysql"` implicitly). Credentials are read from wp-config.php, parameters.php, .env. Same behaviour as before.\n- Direct via Docker container: pass `containerName` (the container running the DB server). Engine defaults to `postgres`; pass `engine: "mysql"` or `"mssql"` to override. The query is piped via stdin so quotes / `$` / `;` are never re-interpreted.\n\nPostgres example: `{ serverId, containerName: "supabase-db", engine: "postgres", dbName: "main", dbUser: "postgres", query: "SELECT 1" }`. Defaults: dbUser=postgres, dbName=postgres.\nMSSQL example: `{ serverId, containerName: "mssql-1", engine: "mssql", dbUser: "sa", dbPass: "...", query: "SELECT 1" }`.\n\nDestructive operations (DROP, TRUNCATE, etc.) are blocked.',
3051
3665
  inputSchema: {
3052
3666
  type: "object",
3053
3667
  properties: {
3054
3668
  serverId: { type: "string", description: "UUID of the SSH server" },
3055
- sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com)" },
3056
- query: { type: "string", description: "SQL query to execute" }
3669
+ query: { type: "string", description: "SQL query to execute" },
3670
+ sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
3671
+ containerName: { type: "string", description: 'Docker container running the DB server (e.g. "supabase-db", "trigger-postgres"). Activates direct-query mode.' },
3672
+ engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
3673
+ dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
3674
+ dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
3675
+ dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." }
3057
3676
  },
3058
- required: ["serverId", "sitePath", "query"]
3677
+ required: ["serverId", "query"]
3059
3678
  }
3060
3679
  },
3061
3680
  {
@@ -3201,9 +3820,21 @@ async function handleCallTool(request) {
3201
3820
  }
3202
3821
  const startTime = Date.now();
3203
3822
  const serverId = a.serverId || a.server_id;
3204
- const result = await executeToolCall(name, a);
3823
+ const serverIds = a.serverIds;
3824
+ const cached = getCachedTool(name, a);
3825
+ let cameFromCache = false;
3826
+ let result;
3827
+ if (cached) {
3828
+ result = cached;
3829
+ cameFromCache = true;
3830
+ } else {
3831
+ result = await executeToolCall(name, a);
3832
+ }
3205
3833
  const durationMs = Date.now() - startTime;
3206
3834
  const isError = result.content?.[0]?.text?.startsWith("Error:");
3835
+ if (!cameFromCache && !isError) {
3836
+ setCachedTool(name, a, result);
3837
+ }
3207
3838
  void writeAuditLog({
3208
3839
  toolName: name,
3209
3840
  arguments: a,
@@ -3212,7 +3843,14 @@ async function handleCallTool(request) {
3212
3843
  errorMessage: isError ? result.content?.[0]?.text : void 0,
3213
3844
  durationMs
3214
3845
  });
3215
- return result;
3846
+ const serverIdLabel = await resolveServerLabel(serverId, serverIds);
3847
+ return postprocessResult(result, {
3848
+ startedAtMs: startTime,
3849
+ serverIdLabel,
3850
+ toolName: name,
3851
+ args: a,
3852
+ cached: cameFromCache
3853
+ });
3216
3854
  }
3217
3855
  async function executeToolCall(name, a, _serverId) {
3218
3856
  const ctx = authContext;
@@ -3228,6 +3866,7 @@ async function executeToolCall(name, a, _serverId) {
3228
3866
  if (error) throw new Error(error.message);
3229
3867
  const includeStats = a.includeStats === true;
3230
3868
  const servers = data || [];
3869
+ rememberServers(servers.map((s) => ({ id: s.id, name: s.name })));
3231
3870
  let statsByServer = /* @__PURE__ */ new Map();
3232
3871
  if (includeStats && servers.length > 0) {
3233
3872
  const parallelism = Math.max(1, Math.min(20, Number(a.statsParallelism) || 8));
@@ -3278,17 +3917,34 @@ async function executeToolCall(name, a, _serverId) {
3278
3917
  if (!st.reachable) return `${base} UNREACHABLE (${st.error || "unknown"})`;
3279
3918
  return `${base} containers:${st.containers} disk_free:${st.diskFreeHuman} uptime:${st.uptimeHuman}`;
3280
3919
  });
3281
- return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No servers found" }] };
3920
+ const contextHints = a.context !== false ? buildServerContextHints(servers) : [];
3921
+ const out = lines.length ? lines.join("\n") : "No servers found";
3922
+ return { content: [{ type: "text", text: contextHints.length ? `${out}
3923
+
3924
+ --- context ---
3925
+ ${contextHints.join("\n")}` : out }] };
3282
3926
  }
3283
3927
  // ----- SSH -----
3284
3928
  case "ssh-execute": {
3285
- const command = String(a.command);
3286
- assertSafeCommand(command);
3929
+ const pipeline = Array.isArray(a.pipeline) ? a.pipeline.map(String).filter(Boolean) : [];
3930
+ const command = pipeline.length > 0 ? "" : String(a.command || "");
3931
+ if (!command && pipeline.length === 0) {
3932
+ return { content: [{ type: "text", text: "Error: pass either `command` or `pipeline[]`" }] };
3933
+ }
3934
+ if (command) assertSafeCommand(command);
3935
+ for (const c of pipeline) assertSafeCommand(c);
3287
3936
  const args2 = Array.isArray(a.args) ? a.args.map(String) : void 0;
3288
3937
  const stdin = typeof a.stdin === "string" ? a.stdin : void 0;
3289
3938
  const shellOverride = typeof a.shell === "string" ? a.shell : "auto";
3290
3939
  const cwd = typeof a.cwd === "string" && a.cwd ? a.cwd : void 0;
3291
3940
  const timeoutMs = a.timeout ? Number(a.timeout) : void 0;
3941
+ const rawIdle = a.idleTimeoutSeconds !== void 0 ? Number(a.idleTimeoutSeconds) : 120;
3942
+ const idleTimeoutMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle * 1e3 : void 0;
3943
+ const repeat = Math.max(1, Math.min(60, Number(a.repeat) || 1));
3944
+ const intervalSeconds = Math.max(0, Math.min(600, Number(a.intervalSeconds) || 0));
3945
+ const repeatOutput = a.output === "diff" || a.output === "last" ? a.output : "all";
3946
+ const format = a.format === "ndjson" ? "ndjson" : "text";
3947
+ const pipelineStopOnError = a.pipelineStopOnError === true;
3292
3948
  const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
3293
3949
  if (targetIds.length === 0) {
3294
3950
  return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
@@ -3299,44 +3955,88 @@ async function executeToolCall(name, a, _serverId) {
3299
3955
  if (timeoutMs) conn.timeout = timeoutMs;
3300
3956
  const shell = shellOverride === "auto" ? os === "windows" ? "powershell" : "bash" : shellOverride;
3301
3957
  let body;
3302
- if (args2 && args2.length > 0) {
3958
+ let pipelineMarker = null;
3959
+ if (pipeline.length > 0) {
3960
+ pipelineMarker = `__MCP_BREAK_${randomBytes(8).toString("hex")}__`;
3961
+ body = buildPipelineScript(pipeline, shell, pipelineMarker, pipelineStopOnError);
3962
+ } else if (args2 && args2.length > 0) {
3303
3963
  body = shell === "powershell" ? buildPowerShellEncodedCommand(command, args2) : buildPosixCommand(command, args2);
3304
3964
  } else if (shell === "powershell" && !/^powershell\b/i.test(command.trim())) {
3305
3965
  body = buildPowerShellEncodedCommand(command, []);
3306
3966
  } else {
3307
3967
  body = command;
3308
3968
  }
3309
- let finalCmd = body;
3310
- if (cwd) {
3311
- if (shell === "bash") {
3312
- finalCmd = `cd ${posixQuote(cwd)} && ${body}`;
3313
- } else {
3314
- const ecMatch = body.match(/-EncodedCommand\s+(\S+)$/);
3315
- if (ecMatch) {
3316
- const decoded = Buffer.from(ecMatch[1], "base64").toString("utf16le");
3317
- const wrapped = `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${decoded}`;
3318
- const reencoded = Buffer.from(wrapped, "utf16le").toString("base64");
3319
- finalCmd = body.replace(/-EncodedCommand\s+\S+$/, `-EncodedCommand ${reencoded}`);
3969
+ const applyCwd = (rawBody) => {
3970
+ if (!cwd) return rawBody;
3971
+ if (shell === "bash") return `cd ${posixQuote(cwd)} && ${rawBody}`;
3972
+ const ecMatch = rawBody.match(/-EncodedCommand\s+(\S+)$/);
3973
+ if (!ecMatch) return rawBody;
3974
+ const decoded = Buffer.from(ecMatch[1], "base64").toString("utf16le");
3975
+ const wrapped = `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${decoded}`;
3976
+ const reencoded = Buffer.from(wrapped, "utf16le").toString("base64");
3977
+ return rawBody.replace(/-EncodedCommand\s+\S+$/, `-EncodedCommand ${reencoded}`);
3978
+ };
3979
+ const finalCmd = applyCwd(body);
3980
+ if (repeat > 1) {
3981
+ const iterations = [];
3982
+ for (let i = 0; i < repeat; i++) {
3983
+ const startedAt = Date.now();
3984
+ const r = await sshExec(conn, finalCmd, proxy, {
3985
+ ...stdin !== void 0 ? { stdin } : {},
3986
+ idleTimeoutMs
3987
+ });
3988
+ iterations.push({ index: i, startedAt, durationMs: Date.now() - startedAt, result: r });
3989
+ if (i < repeat - 1 && intervalSeconds > 0) {
3990
+ await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
3320
3991
  }
3321
3992
  }
3993
+ const last = iterations[iterations.length - 1].result;
3994
+ let serverName2 = serverId;
3995
+ try {
3996
+ const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
3997
+ if (data?.name) serverName2 = data.name;
3998
+ } catch {
3999
+ }
4000
+ return { serverId, serverName: serverName2, os, shell, result: last, iterations };
4001
+ }
4002
+ const result = await sshExec(conn, finalCmd, proxy, {
4003
+ ...stdin !== void 0 ? { stdin } : {},
4004
+ idleTimeoutMs
4005
+ });
4006
+ let pipelineSegments;
4007
+ if (pipelineMarker && pipeline.length > 0) {
4008
+ pipelineSegments = parsePipelineOutput(result.stdout, pipeline, pipelineMarker);
3322
4009
  }
3323
- const result = await sshExec(conn, finalCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
3324
4010
  let serverName = serverId;
3325
4011
  try {
3326
- const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).single();
4012
+ const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
3327
4013
  if (data?.name) serverName = data.name;
3328
4014
  } catch {
3329
4015
  }
3330
- return { serverId, serverName, os, shell, result };
4016
+ return { serverId, serverName, os, shell, result, pipelineSegments };
4017
+ };
4018
+ const renderOne = (r) => {
4019
+ if (r.iterations && r.iterations.length > 0) {
4020
+ return renderRepeatResult(r.iterations, repeatOutput, format);
4021
+ }
4022
+ if (r.pipelineSegments) {
4023
+ return renderPipelineSegments(r.pipelineSegments, r.result, format);
4024
+ }
4025
+ const output = [`Exit code: ${r.result.exitCode} (os: ${r.os}, shell: ${r.shell})`];
4026
+ let stdoutText = r.result.stdout;
4027
+ if (format === "ndjson" && stdoutText) {
4028
+ const parsed = tryParseNdjsonFromCommand(command, stdoutText);
4029
+ if (parsed) stdoutText = parsed;
4030
+ }
4031
+ if (stdoutText) output.push(`--- stdout ---
4032
+ ${stdoutText}`);
4033
+ if (r.result.stderr) output.push(`--- stderr ---
4034
+ ${r.result.stderr}`);
4035
+ return output.join("\n");
3331
4036
  };
3332
4037
  if (targetIds.length === 1) {
3333
- const { os, shell, result } = await runOne(targetIds[0]);
3334
- const output = [`Exit code: ${result.exitCode} (os: ${os}, shell: ${shell})`];
3335
- if (result.stdout) output.push(`--- stdout ---
3336
- ${result.stdout}`);
3337
- if (result.stderr) output.push(`--- stderr ---
3338
- ${result.stderr}`);
3339
- return { content: [{ type: "text", text: output.join("\n") }] };
4038
+ const r = await runOne(targetIds[0]);
4039
+ return { content: [{ type: "text", text: renderOne(r) }] };
3340
4040
  }
3341
4041
  const allResults = [];
3342
4042
  for (let i = 0; i < targetIds.length; i += parallelism) {
@@ -3360,10 +4060,8 @@ ${result.stderr}`);
3360
4060
  if (r.result.exitCode === 0) okCount++;
3361
4061
  else failCount++;
3362
4062
  const header = `=== ${r.serverName} (${r.os}, exit ${r.result.exitCode}) ===`;
3363
- const parts = [header];
3364
- if (r.result.stdout) parts.push(r.result.stdout.trimEnd());
3365
- if (r.result.stderr) parts.push(`[stderr] ${r.result.stderr.trimEnd()}`);
3366
- sections.push(parts.join("\n"));
4063
+ sections.push(`${header}
4064
+ ${renderOne(r)}`);
3367
4065
  }
3368
4066
  sections.push(`--- fan-out summary: ${okCount} ok / ${failCount} failed across ${allResults.length} servers (parallelism ${parallelism}) ---`);
3369
4067
  return { content: [{ type: "text", text: sections.join("\n\n") }] };
@@ -3465,6 +4163,46 @@ ${result.stderr}`);
3465
4163
  }
3466
4164
  case "sftp-write": {
3467
4165
  const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
4166
+ if (a.sourceRemote && typeof a.sourceRemote === "object") {
4167
+ const srcRaw = a.sourceRemote;
4168
+ const srcBucket = typeof srcRaw.bucket === "string" && srcRaw.bucket ? String(srcRaw.bucket) : "";
4169
+ const srcServerId = typeof srcRaw.serverId === "string" && srcRaw.serverId ? String(srcRaw.serverId) : "";
4170
+ const srcPath = String(srcRaw.path || "");
4171
+ if (!srcPath) return { content: [{ type: "text", text: "Error: sourceRemote.path is required" }] };
4172
+ if (!srcBucket && !srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote needs `serverId` or `bucket`" }] };
4173
+ if (srcBucket && srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote can have either `serverId` or `bucket`, not both" }] };
4174
+ const dstPath = String(a.path || "");
4175
+ if (!dstPath) return { content: [{ type: "text", text: "Error: target `path` is required" }] };
4176
+ let srcEp;
4177
+ if (srcBucket) {
4178
+ const k = r2Key(srcPath);
4179
+ if (!k) return { content: [{ type: "text", text: "Error: sourceRemote.path resolves to empty key" }] };
4180
+ srcEp = { kind: "r2", bucket: srcBucket, key: k };
4181
+ } else {
4182
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(srcServerId);
4183
+ srcEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: srcPath };
4184
+ }
4185
+ let dstEp;
4186
+ if (bucket) {
4187
+ const k = r2Key(dstPath);
4188
+ if (!k) return { content: [{ type: "text", text: "Error: target path resolves to empty key" }] };
4189
+ dstEp = { kind: "r2", bucket, key: k };
4190
+ } else if (a.serverId) {
4191
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4192
+ dstEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: dstPath };
4193
+ } else {
4194
+ return { content: [{ type: "text", text: "Error: target needs `serverId` or `bucket`" }] };
4195
+ }
4196
+ try {
4197
+ const { bytes, elapsedMs, mode } = await sftpCopy(srcEp, dstEp);
4198
+ const srcLabel = srcEp.kind === "r2" ? `r2://${srcEp.bucket}/${srcEp.key}` : `ssh:${srcPath}`;
4199
+ const dstLabel = dstEp.kind === "r2" ? `r2://${dstEp.bucket}/${dstEp.key}` : `ssh:${dstPath}`;
4200
+ const mbps = bytes > 0 && elapsedMs > 0 ? bytes / (1024 * 1024) / (elapsedMs / 1e3) : 0;
4201
+ return { content: [{ type: "text", text: `Copied ${formatBytes(bytes)} from ${srcLabel} \u2192 ${dstLabel} via ${mode} in ${(elapsedMs / 1e3).toFixed(2)}s${mbps > 0 ? ` (${mbps.toFixed(1)} MB/s)` : ""}` }] };
4202
+ } catch (e) {
4203
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
4204
+ }
4205
+ }
3468
4206
  let fileMode;
3469
4207
  if (typeof a.mode === "number" && Number.isFinite(a.mode)) fileMode = a.mode & 4095;
3470
4208
  else if (typeof a.mode === "string" && a.mode) {
@@ -3527,14 +4265,51 @@ ${result.stderr}`);
3527
4265
  case "sftp-delete": {
3528
4266
  const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
3529
4267
  const recursive = a.recursive === true;
4268
+ const dryRun = a.dryRun === true;
4269
+ const confirmAbove = a.confirmAbove !== void 0 ? Math.max(0, Number(a.confirmAbove)) : void 0;
4270
+ const confirmCount = a.confirmCount !== void 0 ? Number(a.confirmCount) : void 0;
4271
+ const guard = (count, sizeBytes, where) => {
4272
+ if (confirmAbove !== void 0 && count > confirmAbove) {
4273
+ if (confirmCount === count) return null;
4274
+ return {
4275
+ content: [{
4276
+ type: "text",
4277
+ text: `Refusing to delete ${count} item(s) (${formatBytes(sizeBytes)}) under ${where}: exceeds confirmAbove=${confirmAbove}. To proceed, re-issue the call with confirmCount: ${count}.`
4278
+ }]
4279
+ };
4280
+ }
4281
+ return null;
4282
+ };
3530
4283
  if (bucket) {
3531
4284
  const key = r2Key(String(a.path));
3532
4285
  if (!key) return { content: [{ type: "text", text: "Error: path is required" }] };
3533
4286
  try {
3534
4287
  if (recursive) {
4288
+ if (dryRun || confirmAbove !== void 0) {
4289
+ const listed = await r2List(bucket, key, { recursive: true, maxResults: 5e4 });
4290
+ const objects = listed.filter((e) => !e.isPrefix);
4291
+ const totalSize = objects.reduce((s, o) => s + (o.size || 0), 0);
4292
+ if (dryRun) {
4293
+ const sample = objects.slice(0, 5).map((o) => ` - r2://${bucket}/${o.key} (${formatBytes(o.size || 0)})`);
4294
+ return { content: [{ type: "text", text: `[dry-run] Would delete ${objects.length} object(s) totalling ${formatBytes(totalSize)} under r2://${bucket}/${key}${objects.length > 5 ? `
4295
+ ${sample.join("\n")}
4296
+ ... and ${objects.length - 5} more` : objects.length ? `
4297
+ ${sample.join("\n")}` : ""}` }] };
4298
+ }
4299
+ const block = guard(objects.length, totalSize, `r2://${bucket}/${key}`);
4300
+ if (block) return block;
4301
+ }
3535
4302
  const deleted = await r2DeletePrefix(bucket, key);
3536
4303
  return { content: [{ type: "text", text: `Deleted ${deleted} object(s) under r2://${bucket}/${key}` }] };
3537
4304
  }
4305
+ if (dryRun) {
4306
+ try {
4307
+ const head = await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
4308
+ return { content: [{ type: "text", text: `[dry-run] Would delete r2://${bucket}/${key} (${formatBytes(head.ContentLength || 0)})` }] };
4309
+ } catch {
4310
+ return { content: [{ type: "text", text: `[dry-run] r2://${bucket}/${key} not found (delete would 404)` }] };
4311
+ }
4312
+ }
3538
4313
  await r2DeleteObject(bucket, key);
3539
4314
  return { content: [{ type: "text", text: `Deleted r2://${bucket}/${key}` }] };
3540
4315
  } catch (e) {
@@ -3542,6 +4317,25 @@ ${result.stderr}`);
3542
4317
  }
3543
4318
  }
3544
4319
  const { conn, proxy } = await getServerConnection(String(a.serverId));
4320
+ if (recursive && (dryRun || confirmAbove !== void 0)) {
4321
+ const listed = await sftpReaddir(conn, String(a.path), proxy, {
4322
+ recursive: true,
4323
+ maxResults: 5e4
4324
+ });
4325
+ const files = listed.entries.filter((e) => e.kind === "-");
4326
+ const dirs = listed.entries.filter((e) => e.kind === "d");
4327
+ const totalSize = files.reduce((s, f) => s + (f.size || 0), 0);
4328
+ if (dryRun) {
4329
+ const sample = files.slice(0, 5).map((f) => ` - ${f.path} (${formatBytes(f.size || 0)})`);
4330
+ return { content: [{ type: "text", text: `[dry-run] Would delete ${files.length} file(s) + ${dirs.length} directory(ies) totalling ${formatBytes(totalSize)} under ${a.path}${files.length ? `
4331
+ ${sample.join("\n")}${files.length > 5 ? `
4332
+ ... and ${files.length - 5} more` : ""}` : ""}` }] };
4333
+ }
4334
+ const block = guard(files.length + dirs.length, totalSize, String(a.path));
4335
+ if (block) return block;
4336
+ } else if (dryRun) {
4337
+ return { content: [{ type: "text", text: `[dry-run] Would delete ${a.path} (single file/dir; pass recursive: true to preview tree contents)` }] };
4338
+ }
3545
4339
  const result = await sftpDelete(conn, String(a.path), proxy, { recursive });
3546
4340
  return { content: [{ type: "text", text: result }] };
3547
4341
  }
@@ -3551,75 +4345,136 @@ ${result.stderr}`);
3551
4345
  const composeOnly = a.composeOnly === true;
3552
4346
  const groupByProject = a.groupByProject === true;
3553
4347
  const nameFiltersRaw = Array.isArray(a.nameFilter) ? a.nameFilter.map((v) => String(v).trim()).filter(Boolean) : typeof a.nameFilter === "string" && a.nameFilter.trim() ? [a.nameFilter.trim()] : [];
4348
+ const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
4349
+ if (targetIds.length === 0) {
4350
+ return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
4351
+ }
4352
+ const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
3554
4353
  const filterArg = composeOnly ? ` --filter "label=com.docker.compose.project"` : "";
3555
4354
  const cmd = `docker ps -a${filterArg} --format '{{json .}}'`;
3556
- const { conn, proxy } = await getServerConnection(String(a.serverId));
3557
- const result = await sshExec(conn, cmd, proxy);
3558
- if (result.exitCode !== 0) {
3559
- return { content: [{ type: "text", text: `Error: ${result.stderr || result.stdout}` }] };
3560
- }
3561
- const containers = [];
3562
- for (const line of result.stdout.split("\n")) {
3563
- const trimmed = line.trim();
3564
- if (!trimmed) continue;
4355
+ const probeOne = async (serverId) => {
4356
+ let serverName = serverId.slice(0, 8);
3565
4357
  try {
3566
- containers.push(JSON.parse(trimmed));
3567
- } catch {
4358
+ const { conn, proxy } = await getServerConnection(serverId);
4359
+ const r = await sshExec(conn, cmd, proxy);
4360
+ const cached = KNOWN_SERVER_NAMES.get(serverId);
4361
+ if (cached) serverName = cached.name;
4362
+ if (r.exitCode !== 0) {
4363
+ return { serverId, serverName, rows: [], error: (r.stderr || r.stdout || "exit " + r.exitCode).trim().slice(0, 200) };
4364
+ }
4365
+ const rows = [];
4366
+ for (const line of r.stdout.split("\n")) {
4367
+ const t = line.trim();
4368
+ if (!t) continue;
4369
+ try {
4370
+ rows.push(JSON.parse(t));
4371
+ } catch {
4372
+ }
4373
+ }
4374
+ for (const c of rows) {
4375
+ const m = (c.Labels || "").match(/com\.docker\.compose\.project=([^,]+)/);
4376
+ c.Project = m ? m[1] : "";
4377
+ const status = c.Status || "";
4378
+ if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
4379
+ else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
4380
+ else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
4381
+ else if (/^Up\b/i.test(status)) c.Health = "no-check";
4382
+ else c.Health = "";
4383
+ }
4384
+ rememberContainers(serverId, rows.map((r2) => r2.Names || "").filter(Boolean));
4385
+ return { serverId, serverName, rows };
4386
+ } catch (err) {
4387
+ return { serverId, serverName, rows: [], error: err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200) };
3568
4388
  }
4389
+ };
4390
+ const perServer = [];
4391
+ for (let i = 0; i < targetIds.length; i += parallelism) {
4392
+ const batch = targetIds.slice(i, i + parallelism);
4393
+ const chunk = await Promise.all(batch.map(probeOne));
4394
+ perServer.push(...chunk);
3569
4395
  }
3570
- for (const c of containers) {
3571
- const m = (c.Labels || "").match(/com\.docker\.compose\.project=([^,]+)/);
3572
- c.Project = m ? m[1] : "";
3573
- const status = c.Status || "";
3574
- if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
3575
- else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
3576
- else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
3577
- else if (/^Up\b/i.test(status)) c.Health = "no-check";
3578
- else c.Health = "";
3579
- }
3580
- let filtered = containers;
3581
- if (nameFiltersRaw.length > 0) {
4396
+ const applyFilter = (rows) => {
4397
+ if (nameFiltersRaw.length === 0) return rows;
3582
4398
  const matchers = nameFiltersRaw.map((raw) => {
3583
4399
  if (raw.includes("*") || raw.includes("?")) return { kind: "glob", re: globToRegExp(raw) };
3584
4400
  return { kind: "sub", lower: raw.toLowerCase() };
3585
4401
  });
3586
- filtered = containers.filter((c) => {
4402
+ return rows.filter((c) => {
3587
4403
  const name2 = c.Names || "";
3588
4404
  const lower = name2.toLowerCase();
3589
4405
  return matchers.some((m) => m.kind === "glob" ? m.re.test(name2) : lower.includes(m.lower));
3590
4406
  });
3591
- }
3592
- filtered.sort((x, y) => {
4407
+ };
4408
+ const sortRows = (rows) => rows.sort((x, y) => {
3593
4409
  const px = x.Project || "~";
3594
4410
  const py = y.Project || "~";
3595
4411
  const p = px.localeCompare(py);
3596
4412
  return p !== 0 ? p : (x.Names || "").localeCompare(y.Names || "");
3597
4413
  });
4414
+ const fmtRow = (r) => `${(r.Names || "").padEnd(36)} ${(r.Image || "").padEnd(40)} ${(r.Status || "").padEnd(24)} ${(r.Health || "").padEnd(10)} ${(r.Ports || "").padEnd(40)}`;
4415
+ if (targetIds.length === 1) {
4416
+ const only = perServer[0];
4417
+ if (only.error) return { content: [{ type: "text", text: `Error: ${only.error}` }] };
4418
+ const filtered = sortRows(applyFilter(only.rows));
4419
+ if (format === "json") {
4420
+ const text2 = filtered.map((c) => JSON.stringify(c)).join("\n") || "(no containers)";
4421
+ return { content: [{ type: "text", text: text2 }] };
4422
+ }
4423
+ if (groupByProject) {
4424
+ const groups = /* @__PURE__ */ new Map();
4425
+ for (const c of filtered) {
4426
+ const key = c.Project || "(no compose project)";
4427
+ const list = groups.get(key);
4428
+ if (list) list.push(c);
4429
+ else groups.set(key, [c]);
4430
+ }
4431
+ const out = [];
4432
+ for (const [project, rows] of groups) {
4433
+ out.push(`=== ${project} (${rows.length}) ===`);
4434
+ for (const r of rows) out.push(" " + fmtRow(r));
4435
+ out.push("");
4436
+ }
4437
+ return { content: [{ type: "text", text: out.join("\n").trimEnd() || "(no containers)" }] };
4438
+ }
4439
+ const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
4440
+ const body = filtered.map((r) => `${fmtRow(r)} ${r.Project || ""}`);
4441
+ const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
4442
+ return { content: [{ type: "text", text }] };
4443
+ }
3598
4444
  if (format === "json") {
3599
- const text2 = filtered.map((c) => JSON.stringify(c)).join("\n") || "(no containers)";
3600
- return { content: [{ type: "text", text: text2 }] };
4445
+ const lines = [];
4446
+ for (const ps of perServer) {
4447
+ if (ps.error) {
4448
+ lines.push(JSON.stringify({ Server: ps.serverName, Error: ps.error }));
4449
+ continue;
4450
+ }
4451
+ const filtered = sortRows(applyFilter(ps.rows));
4452
+ for (const c of filtered) lines.push(JSON.stringify({ Server: ps.serverName, ...c }));
4453
+ }
4454
+ return { content: [{ type: "text", text: lines.join("\n") || "(no containers)" }] };
3601
4455
  }
3602
- const fmtRow = (r) => `${(r.Names || "").padEnd(36)} ${(r.Image || "").padEnd(40)} ${(r.Status || "").padEnd(24)} ${(r.Health || "").padEnd(10)} ${(r.Ports || "").padEnd(40)}`;
3603
- if (groupByProject) {
3604
- const groups = /* @__PURE__ */ new Map();
3605
- for (const c of filtered) {
3606
- const key = c.Project || "(no compose project)";
3607
- const list = groups.get(key);
3608
- if (list) list.push(c);
3609
- else groups.set(key, [c]);
4456
+ const sections = [];
4457
+ let totalRows = 0;
4458
+ let serversWithRows = 0;
4459
+ for (const ps of perServer) {
4460
+ if (ps.error) {
4461
+ sections.push(`=== ${ps.serverName} === !! ${ps.error}`);
4462
+ continue;
3610
4463
  }
3611
- const out = [];
3612
- for (const [project, rows] of groups) {
3613
- out.push(`=== ${project} (${rows.length}) ===`);
3614
- for (const r of rows) out.push(" " + fmtRow(r));
3615
- out.push("");
4464
+ const filtered = sortRows(applyFilter(ps.rows));
4465
+ if (filtered.length === 0) {
4466
+ sections.push(`=== ${ps.serverName} === (no containers match)`);
4467
+ continue;
3616
4468
  }
3617
- return { content: [{ type: "text", text: out.join("\n").trimEnd() || "(no containers)" }] };
4469
+ serversWithRows++;
4470
+ totalRows += filtered.length;
4471
+ sections.push(`=== ${ps.serverName} (${filtered.length} container(s)) ===`);
4472
+ const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
4473
+ sections.push(header);
4474
+ for (const r of filtered) sections.push(`${fmtRow(r)} ${r.Project || ""}`);
3618
4475
  }
3619
- const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
3620
- const body = filtered.map((r) => `${fmtRow(r)} ${r.Project || ""}`);
3621
- const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
3622
- return { content: [{ type: "text", text }] };
4476
+ sections.push(`--- fan-out summary: ${totalRows} container(s) across ${serversWithRows}/${perServer.length} server(s) ---`);
4477
+ return { content: [{ type: "text", text: sections.join("\n") }] };
3623
4478
  }
3624
4479
  case "docker-logs": {
3625
4480
  const rawNames = Array.isArray(a.containerName) ? a.containerName.map(String) : [String(a.containerName)];
@@ -3637,37 +4492,71 @@ ${result.stderr}`);
3637
4492
  }
3638
4493
  const sinceArg = sinceRaw ? ` --since ${posixQuote(sinceRaw)}` : "";
3639
4494
  const grepSuffix = grepRaw ? ` | grep -i -E ${posixQuote(grepRaw)} --line-buffered` : "";
3640
- const { conn, proxy } = await getServerConnection(String(a.serverId));
4495
+ const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
4496
+ if (targetIds.length === 0) {
4497
+ return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
4498
+ }
4499
+ const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
3641
4500
  const wallSeconds = followSeconds > 0 ? followSeconds : 30;
3642
- conn.timeout = (wallSeconds + 10) * 1e3;
3643
4501
  const followFlag = followSeconds > 0 ? " -f" : "";
3644
- if (containers.length === 1) {
3645
- const c = containers[0];
3646
- const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
3647
- const cmd2 = `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(inner)}`;
3648
- const result2 = await sshExec(conn, cmd2, proxy);
3649
- const acceptable2 = result2.exitCode === 0 || result2.exitCode === 124 || result2.exitCode === 130 || result2.exitCode === 143 || !!grepRaw && result2.exitCode === 1;
3650
- if (!acceptable2 && !result2.stdout) {
3651
- return { content: [{ type: "text", text: `Error (exit ${result2.exitCode}): ${result2.stderr || "(no output)"}` }] };
4502
+ const buildCmd = () => {
4503
+ if (containers.length === 1) {
4504
+ const c = containers[0];
4505
+ const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
4506
+ return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(inner)}`;
3652
4507
  }
3653
- const note2 = result2.exitCode === 124 ? `
4508
+ const subShells = containers.map((c) => {
4509
+ const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
4510
+ return `(${inner} | sed -u -e ${posixQuote(`s/^/[${c}] /`)})`;
4511
+ });
4512
+ const innerCmd = subShells.join(" & ") + " & wait";
4513
+ return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(innerCmd)}`;
4514
+ };
4515
+ const cmd = buildCmd();
4516
+ const probeOne = async (serverId) => {
4517
+ const { conn, proxy } = await getServerConnection(serverId);
4518
+ conn.timeout = (wallSeconds + 10) * 1e3;
4519
+ const result = await sshExec(conn, cmd, proxy);
4520
+ const cached = KNOWN_SERVER_NAMES.get(serverId);
4521
+ return { serverId, serverName: cached?.name || serverId.slice(0, 8), result };
4522
+ };
4523
+ const acceptableCode = (code) => code === 0 || code === 124 || code === 130 || code === 143 || !!grepRaw && code === 1;
4524
+ if (targetIds.length === 1) {
4525
+ const r = await probeOne(targetIds[0]);
4526
+ const result = r.result;
4527
+ if (!acceptableCode(result.exitCode) && !result.stdout) {
4528
+ const known = getKnownContainers(targetIds[0]);
4529
+ if (known && containers.length === 1) {
4530
+ const hits = suggestSimilar(containers[0], known);
4531
+ const hint = hits.length ? ` Did you mean: ${hits.join(", ")}?` : "";
4532
+ return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}${hint}` }] };
4533
+ }
4534
+ return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}` }] };
4535
+ }
4536
+ const note = result.exitCode === 124 ? `
3654
4537
  (note: command exceeded ${wallSeconds}s wall budget; partial output)` : "";
3655
- return { content: [{ type: "text", text: (result2.stdout || "(no log lines matched)") + note2 }] };
4538
+ return { content: [{ type: "text", text: (result.stdout || "(no log lines matched)") + note }] };
3656
4539
  }
3657
- const subShells = containers.map((c) => {
3658
- const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
3659
- return `(${inner} | sed -u -e ${posixQuote(`s/^/[${c}] /`)})`;
3660
- });
3661
- const innerCmd = subShells.join(" & ") + " & wait";
3662
- const cmd = `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(innerCmd)}`;
3663
- const result = await sshExec(conn, cmd, proxy);
3664
- const acceptable = result.exitCode === 0 || result.exitCode === 124 || result.exitCode === 130 || result.exitCode === 143 || !!grepRaw && result.exitCode === 1;
3665
- if (!acceptable && !result.stdout) {
3666
- return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}` }] };
4540
+ const perServer = [];
4541
+ for (let i = 0; i < targetIds.length; i += parallelism) {
4542
+ const batch = targetIds.slice(i, i + parallelism);
4543
+ const chunk = await Promise.all(batch.map(probeOne));
4544
+ perServer.push(...chunk);
3667
4545
  }
3668
- const note = result.exitCode === 124 ? `
3669
- (note: one or more containers exceeded ${wallSeconds}s wall budget; partial output)` : "";
3670
- return { content: [{ type: "text", text: (result.stdout || "(no log lines matched)") + note }] };
4546
+ const sections = [];
4547
+ let okCount = 0;
4548
+ for (const ps of perServer) {
4549
+ if (acceptableCode(ps.result.exitCode) || ps.result.stdout) {
4550
+ okCount++;
4551
+ const note = ps.result.exitCode === 124 ? ` (timed out after ${wallSeconds}s; partial)` : "";
4552
+ sections.push(`=== ${ps.serverName}${note} ===`);
4553
+ sections.push(ps.result.stdout || "(no log lines matched)");
4554
+ } else {
4555
+ sections.push(`=== ${ps.serverName} === !! exit ${ps.result.exitCode}: ${ps.result.stderr.trim().slice(0, 200) || "(no output)"}`);
4556
+ }
4557
+ }
4558
+ sections.push(`--- fan-out summary: ${okCount}/${perServer.length} server(s) returned logs ---`);
4559
+ return { content: [{ type: "text", text: sections.join("\n") }] };
3671
4560
  }
3672
4561
  case "docker-exec": {
3673
4562
  const container = String(a.container).replace(/[^a-zA-Z0-9._-]/g, "");
@@ -3692,8 +4581,20 @@ ${result.stderr}`);
3692
4581
  const output = [`Exit code: ${result.exitCode}`];
3693
4582
  if (result.stdout) output.push(`--- stdout ---
3694
4583
  ${result.stdout}`);
3695
- if (result.stderr) output.push(`--- stderr ---
3696
- ${result.stderr}`);
4584
+ if (result.stderr) {
4585
+ let stderrOut = result.stderr;
4586
+ const noSuch = /No such container:\s*(\S+)/i.exec(result.stderr);
4587
+ if (noSuch) {
4588
+ const known = getKnownContainers(String(a.serverId));
4589
+ if (known && known.length) {
4590
+ const hits = suggestSimilar(noSuch[1], known);
4591
+ if (hits.length) stderrOut += `
4592
+ (hint) Did you mean: ${hits.join(", ")}?`;
4593
+ }
4594
+ }
4595
+ output.push(`--- stderr ---
4596
+ ${stderrOut}`);
4597
+ }
3697
4598
  return { content: [{ type: "text", text: output.join("\n") }] };
3698
4599
  }
3699
4600
  case "docker-compose": {
@@ -3745,6 +4646,94 @@ ${result.stderr}`);
3745
4646
  ${result.stderr}`);
3746
4647
  return { content: [{ type: "text", text: output.join("\n") }] };
3747
4648
  }
4649
+ // ----- wait-for: poll until a condition holds (one tool, four types) -----
4650
+ case "wait-for": {
4651
+ const type = String(a.type || "");
4652
+ if (!["url", "container-health", "file-exists", "sftp-exists"].includes(type)) {
4653
+ return { content: [{ type: "text", text: "Error: type must be one of: url, container-health, file-exists, sftp-exists" }] };
4654
+ }
4655
+ const timeoutSeconds = Math.max(1, Math.min(1800, Number(a.timeout) || 120));
4656
+ const intervalSeconds = Math.max(1, Math.min(60, Number(a.intervalSeconds) || 3));
4657
+ const deadline = Date.now() + timeoutSeconds * 1e3;
4658
+ const trail = [];
4659
+ const check = async () => {
4660
+ if (type === "url") {
4661
+ const target = String(a.target || "");
4662
+ if (!target) return { ok: false, observed: "no target url" };
4663
+ const expectStatus = a.until !== void 0 ? Number(a.until) : 200;
4664
+ try {
4665
+ const ctrl = new AbortController();
4666
+ const t = setTimeout(() => ctrl.abort(), 1e4);
4667
+ const res = await fetch(target, { signal: ctrl.signal, redirect: "manual" });
4668
+ clearTimeout(t);
4669
+ const ok2 = res.status === expectStatus || expectStatus === 200 && res.status >= 200 && res.status < 300;
4670
+ return { ok: ok2, observed: `HTTP ${res.status}` };
4671
+ } catch (e) {
4672
+ return { ok: false, observed: e instanceof Error ? e.message : String(e) };
4673
+ }
4674
+ }
4675
+ if (type === "container-health") {
4676
+ const containerName = String(a.target || "").replace(/[^a-zA-Z0-9._-]/g, "");
4677
+ const until = String(a.until || "healthy");
4678
+ if (!containerName) return { ok: false, observed: "no container name" };
4679
+ if (!a.serverId) return { ok: false, observed: "serverId is required for container-health" };
4680
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4681
+ const cmd2 = `docker inspect --format '{{json .State}}' ${containerName} 2>&1`;
4682
+ const r2 = await sshExec(conn2, cmd2, proxy2);
4683
+ if (r2.exitCode !== 0) return { ok: false, observed: r2.stderr.trim().slice(0, 80) || "inspect failed" };
4684
+ try {
4685
+ const state = JSON.parse(r2.stdout.trim());
4686
+ const healthStatus = state.Health?.Status;
4687
+ if (until === "healthy") {
4688
+ return { ok: healthStatus === "healthy", observed: `Status=${state.Status} Health=${healthStatus || "no-check"}` };
4689
+ }
4690
+ return { ok: state.Status === until, observed: `Status=${state.Status}` };
4691
+ } catch (e) {
4692
+ return { ok: false, observed: "parse error: " + (e instanceof Error ? e.message : String(e)) };
4693
+ }
4694
+ }
4695
+ const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
4696
+ const path = String(a.target || a.path || "");
4697
+ if (!path) return { ok: false, observed: "no path" };
4698
+ if (bucket) {
4699
+ try {
4700
+ await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: r2Key(path) }));
4701
+ return { ok: true, observed: `r2://${bucket}/${path} exists` };
4702
+ } catch {
4703
+ return { ok: false, observed: `r2://${bucket}/${path} not found` };
4704
+ }
4705
+ }
4706
+ if (!a.serverId) return { ok: false, observed: "serverId or bucket is required for file-exists" };
4707
+ const { conn, proxy } = await getServerConnection(String(a.serverId));
4708
+ const safe = sanitizePath(path);
4709
+ const cmd = `test -e ${posixQuote(safe)} && echo OK || echo MISSING`;
4710
+ const r = await sshExec(conn, cmd, proxy);
4711
+ const ok = r.stdout.trim() === "OK";
4712
+ return { ok, observed: ok ? `${safe} exists` : `${safe} missing` };
4713
+ };
4714
+ let attempts = 0;
4715
+ let lastObserved = "";
4716
+ while (Date.now() < deadline) {
4717
+ attempts++;
4718
+ const r = await check();
4719
+ lastObserved = r.observed;
4720
+ trail.push(`[${attempts}] ${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)} ${r.ok ? "OK" : ".."} ${r.observed}`);
4721
+ if (r.ok) {
4722
+ return { content: [{ type: "text", text: `Condition met after ${attempts} attempt(s) in ${((Date.now() - (deadline - timeoutSeconds * 1e3)) / 1e3).toFixed(1)}s.
4723
+ Last observation: ${r.observed}
4724
+
4725
+ --- trail ---
4726
+ ${trail.join("\n")}` }] };
4727
+ }
4728
+ if (Date.now() + intervalSeconds * 1e3 >= deadline) break;
4729
+ await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
4730
+ }
4731
+ return { content: [{ type: "text", text: `Timed out after ${timeoutSeconds}s without seeing condition (${attempts} probe(s)).
4732
+ Last observation: ${lastObserved}
4733
+
4734
+ --- trail ---
4735
+ ${trail.join("\n")}` }] };
4736
+ }
3748
4737
  // ----- Database -----
3749
4738
  case "db-discover": {
3750
4739
  const { conn, proxy } = await getServerConnection(String(a.serverId));
@@ -3776,10 +4765,55 @@ ${result.stderr}`);
3776
4765
  }
3777
4766
  case "db-query": {
3778
4767
  const query = String(a.query).trim();
4768
+ if (!query) return { content: [{ type: "text", text: "Error: query is required" }] };
3779
4769
  assertSafeSql(query);
4770
+ const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName.replace(/[^a-zA-Z0-9._-]/g, "") : "";
4771
+ const explicitEngine = a.engine === "mysql" || a.engine === "postgres" || a.engine === "mssql" ? a.engine : null;
4772
+ const engine = explicitEngine || (containerName ? "postgres" : "mysql");
4773
+ if (!containerName && engine === "mysql") {
4774
+ if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
4775
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4776
+ const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
4777
+ return { content: [{ type: "text", text: output2 || "Query executed successfully (no output)" }] };
4778
+ }
4779
+ if (!containerName) {
4780
+ return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
4781
+ }
4782
+ const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName.replace(/[^a-zA-Z0-9_-]/g, "") : "";
4783
+ const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") : "";
3780
4784
  const { conn, proxy } = await getServerConnection(String(a.serverId));
3781
- const output = await execSiteMysql(conn, String(a.sitePath), query, proxy);
3782
- return { content: [{ type: "text", text: output || "Query executed successfully (no output)" }] };
4785
+ let cmd;
4786
+ let stdinPayload;
4787
+ if (engine === "postgres") {
4788
+ const user = dbUser || "postgres";
4789
+ const db = dbName || "postgres";
4790
+ cmd = `docker exec -i ${containerName} psql -U ${user} -d ${db} -P pager=off`;
4791
+ stdinPayload = query.endsWith(";") ? query : `${query};`;
4792
+ } else if (engine === "mssql") {
4793
+ const user = dbUser || "sa";
4794
+ const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
4795
+ if (!dbPass) return { content: [{ type: "text", text: "Error: mssql requires dbPass (sqlcmd has no equivalent of pg trust auth)" }] };
4796
+ const dbArg = dbName ? `-d ${dbName} ` : "";
4797
+ cmd = `docker exec -i -e MSSQL_SA_PASSWORD=${posixQuote(dbPass)} ${containerName} /opt/mssql-tools18/bin/sqlcmd -S localhost -U ${user} -P "$MSSQL_SA_PASSWORD" -C ${dbArg}-h -1`;
4798
+ stdinPayload = query.endsWith(";") ? `${query}
4799
+ GO
4800
+ ` : `${query};
4801
+ GO
4802
+ `;
4803
+ } else {
4804
+ const user = dbUser || "root";
4805
+ const db = dbName ? ` ${dbName}` : "";
4806
+ const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
4807
+ const passArg = dbPass ? `-p${posixQuote(dbPass)} ` : "";
4808
+ cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${db}`;
4809
+ stdinPayload = query.endsWith(";") ? query : `${query};`;
4810
+ }
4811
+ const result = await sshExec(conn, cmd, proxy, { stdin: stdinPayload });
4812
+ const output = result.stdout.trim() || result.stderr.trim() || "(no output)";
4813
+ if (result.exitCode !== 0 && !result.stdout) {
4814
+ return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
4815
+ }
4816
+ return { content: [{ type: "text", text: output }] };
3783
4817
  }
3784
4818
  // ----- Env Config -----
3785
4819
  case "env-list": {