@mgsoftwarebv/mg-dashboard-mcp 3.10.2 → 3.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  });
@@ -2307,6 +2364,44 @@ async function r2PutObjectMultipart(bucket, key, localPath, totalBytes, onProgre
2307
2364
  throw e;
2308
2365
  }
2309
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
+ }
2310
2405
  function sanitizePath(path) {
2311
2406
  let normalized = path.replace(/\\/g, "/").replace(/\0/g, "");
2312
2407
  const parts = normalized.split("/");
@@ -2501,6 +2596,369 @@ function formatBytes(bytes) {
2501
2596
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
2502
2597
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
2503
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 cleanHeader = header.replace(/Mounted on/i, "MountedOn");
2883
+ const cols = cleanHeader.trim().split(/\s+/);
2884
+ const out = [];
2885
+ for (const ln of lines.slice(1)) {
2886
+ const parts = splitCols(ln, cols.length);
2887
+ if (parts.length < 4) continue;
2888
+ const obj = {};
2889
+ for (let i = 0; i < cols.length; i++) obj[cols[i]] = parts[i] || "";
2890
+ out.push(JSON.stringify(obj));
2891
+ }
2892
+ return out.length ? out.join("\n") : null;
2893
+ }
2894
+ function parseFree(s) {
2895
+ const lines = s.split("\n").filter(Boolean);
2896
+ const out = [];
2897
+ for (const ln of lines) {
2898
+ if (/^\s*total\s+used\s+free/i.test(ln)) continue;
2899
+ const m = ln.match(/^(\S+):?\s+(.+)$/);
2900
+ if (!m) continue;
2901
+ const [type, rest] = [m[1], m[2]];
2902
+ const cols = rest.trim().split(/\s+/);
2903
+ out.push(JSON.stringify({ type: type.replace(/:$/, ""), values: cols.map(Number) }));
2904
+ }
2905
+ return out.length ? out.join("\n") : null;
2906
+ }
2907
+ function parsePsAux(s) {
2908
+ const lines = s.split("\n").filter(Boolean);
2909
+ if (!lines[0] || !/USER\s+PID/i.test(lines[0])) return null;
2910
+ const headers = lines[0].trim().split(/\s+/);
2911
+ const out = [];
2912
+ for (const ln of lines.slice(1)) {
2913
+ const parts = splitCols(ln, headers.length);
2914
+ if (parts.length < headers.length) continue;
2915
+ const obj = {};
2916
+ for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
2917
+ out.push(JSON.stringify(obj));
2918
+ }
2919
+ return out.length ? out.join("\n") : null;
2920
+ }
2921
+ function parseSs(s) {
2922
+ const lines = s.split("\n").filter(Boolean);
2923
+ if (!lines[0] || !/(State|Netid)/i.test(lines[0])) return null;
2924
+ const headers = lines[0].trim().split(/\s+/);
2925
+ const out = [];
2926
+ for (const ln of lines.slice(1)) {
2927
+ const parts = splitCols(ln, headers.length);
2928
+ const obj = {};
2929
+ for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
2930
+ out.push(JSON.stringify(obj));
2931
+ }
2932
+ return out.length ? out.join("\n") : null;
2933
+ }
2934
+ function parseLsblk(s) {
2935
+ const lines = s.split("\n").filter(Boolean);
2936
+ if (!lines[0] || !/NAME\s+MAJ:MIN/i.test(lines[0])) return null;
2937
+ const headers = lines[0].trim().split(/\s+/);
2938
+ const out = [];
2939
+ for (const ln of lines.slice(1)) {
2940
+ const parts = splitCols(ln, headers.length);
2941
+ const obj = {};
2942
+ for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
2943
+ out.push(JSON.stringify(obj));
2944
+ }
2945
+ return out.length ? out.join("\n") : null;
2946
+ }
2947
+ async function resolveServerLabel(serverId, serverIds) {
2948
+ if (Array.isArray(serverIds) && serverIds.length > 0) return `${serverIds.length} servers`;
2949
+ if (!serverId) return void 0;
2950
+ const cached = KNOWN_SERVER_NAMES.get(serverId);
2951
+ if (cached) return cached.name;
2952
+ try {
2953
+ const { data } = await supabase.from("ssh_server").select("id, name").eq("id", serverId).maybeSingle();
2954
+ if (data?.name) {
2955
+ KNOWN_SERVER_NAMES.set(serverId, { id: serverId, name: data.name });
2956
+ return data.name;
2957
+ }
2958
+ } catch {
2959
+ }
2960
+ return serverId.slice(0, 8);
2961
+ }
2504
2962
  async function sftpWrite(opts, filePath, input, proxy, meta) {
2505
2963
  const safe = sanitizePath(filePath);
2506
2964
  assertWritablePath(safe);
@@ -2701,6 +3159,92 @@ async function sftpDelete(opts, filePath, proxy, options) {
2701
3159
  return `Error: ${e.message}`;
2702
3160
  }
2703
3161
  }
3162
+ async function sftpCopy(src, dst) {
3163
+ const startedAt = Date.now();
3164
+ if (src.kind === "r2" && dst.kind === "r2") {
3165
+ const { size } = await r2CopyObject(src.bucket, src.key, dst.bucket, dst.key);
3166
+ return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2-server-side" };
3167
+ }
3168
+ if (src.kind === "r2" && dst.kind === "ssh") {
3169
+ const safe = sanitizePath(dst.path);
3170
+ assertWritablePath(safe);
3171
+ const { stream, size } = await r2GetObjectStream(src.bucket, src.key);
3172
+ const { client, cleanup } = await connectSshClient(dst.opts, dst.proxy, 6e4);
3173
+ try {
3174
+ await new Promise((resolve, reject) => {
3175
+ client.sftp((err, sftp) => {
3176
+ if (err) return reject(err);
3177
+ const ws = sftp.createWriteStream(safe);
3178
+ ws.on("close", () => resolve());
3179
+ ws.on("error", reject);
3180
+ stream.on("error", reject);
3181
+ stream.pipe(ws);
3182
+ });
3183
+ });
3184
+ } finally {
3185
+ cleanup();
3186
+ }
3187
+ return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2\u2192ssh-stream" };
3188
+ }
3189
+ if (src.kind === "ssh" && dst.kind === "r2") {
3190
+ const safeSrc = sanitizePath(src.path);
3191
+ const { client, cleanup } = await connectSshClient(src.opts, src.proxy, 6e4);
3192
+ try {
3193
+ const { stream, size } = await new Promise((resolve, reject) => {
3194
+ client.sftp((err, sftp) => {
3195
+ if (err) return reject(err);
3196
+ sftp.stat(safeSrc, (statErr, stats) => {
3197
+ if (statErr) return reject(statErr);
3198
+ const s = stats.size || 0;
3199
+ const rs = sftp.createReadStream(safeSrc);
3200
+ resolve({ stream: rs, size: s });
3201
+ });
3202
+ });
3203
+ });
3204
+ if (size > R2_MULTIPART_THRESHOLD) {
3205
+ cleanup();
3206
+ 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.`);
3207
+ }
3208
+ await r2PutObject(dst.bucket, dst.key, stream, size);
3209
+ return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192r2-stream" };
3210
+ } finally {
3211
+ cleanup();
3212
+ }
3213
+ }
3214
+ if (src.kind === "ssh" && dst.kind === "ssh") {
3215
+ const safeSrc = sanitizePath(src.path);
3216
+ const safeDst = sanitizePath(dst.path);
3217
+ assertWritablePath(safeDst);
3218
+ const srcConn = await connectSshClient(src.opts, src.proxy, 6e4);
3219
+ let dstConn;
3220
+ try {
3221
+ dstConn = await connectSshClient(dst.opts, dst.proxy, 6e4);
3222
+ const bytes = await new Promise((resolve, reject) => {
3223
+ srcConn.client.sftp((err, srcSftp) => {
3224
+ if (err) return reject(err);
3225
+ dstConn.client.sftp((err2, dstSftp) => {
3226
+ if (err2) return reject(err2);
3227
+ srcSftp.stat(safeSrc, (statErr, stats) => {
3228
+ if (statErr) return reject(statErr);
3229
+ const size = stats.size || 0;
3230
+ const rs = srcSftp.createReadStream(safeSrc);
3231
+ const ws = dstSftp.createWriteStream(safeDst);
3232
+ ws.on("close", () => resolve(size));
3233
+ ws.on("error", reject);
3234
+ rs.on("error", reject);
3235
+ rs.pipe(ws);
3236
+ });
3237
+ });
3238
+ });
3239
+ });
3240
+ return { bytes, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192ssh-stream" };
3241
+ } finally {
3242
+ srcConn.cleanup();
3243
+ dstConn?.cleanup();
3244
+ }
3245
+ }
3246
+ throw new Error("Unsupported copy combination");
3247
+ }
2704
3248
  var BLOCKED_COMMANDS = [
2705
3249
  "rm -rf /",
2706
3250
  "rm -fr /",
@@ -2885,33 +3429,41 @@ async function mijnhostFetch(path, options = {}) {
2885
3429
  var TOOLS = [
2886
3430
  {
2887
3431
  name: "list-servers",
2888
- 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).",
3432
+ 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`).',
2889
3433
  inputSchema: {
2890
3434
  type: "object",
2891
3435
  properties: {
2892
3436
  includeStats: { type: "boolean", description: "When true, probe each server (in parallel, with timeout) for container count, disk-free, and uptime." },
2893
3437
  statsParallelism: { type: "number", description: "Max parallel probes when includeStats is true (default 8, cap 20)." },
2894
- statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." }
3438
+ statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." },
3439
+ context: { type: "boolean", description: "Include auto-context footer with name/tag-based hints (default true). Pass false to suppress." },
3440
+ noCache: { type: "boolean", description: "Bypass the 60s in-memory cache." }
2895
3441
  }
2896
3442
  }
2897
3443
  },
2898
3444
  {
2899
3445
  name: "ssh-execute",
2900
- 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.',
3446
+ 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.',
2901
3447
  inputSchema: {
2902
3448
  type: "object",
2903
3449
  properties: {
2904
3450
  serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
2905
3451
  serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to run the command on in parallel. Mutually exclusive with serverId." },
2906
- command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required.' },
3452
+ command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required unless `pipeline[]` is set.' },
2907
3453
  args: { type: "array", items: { type: "string" }, description: "Optional argv list. When provided, each entry is safely quoted/encoded for the target OS." },
3454
+ 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." },
3455
+ 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)." },
3456
+ repeat: { type: "number", description: 'Re-run the command/pipeline N times (default 1, max 60). Combine with intervalSeconds + output to "watch" a metric.' },
3457
+ intervalSeconds: { type: "number", description: "When repeat>1, seconds to wait between iterations (default 0, max 600)." },
3458
+ 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." },
3459
+ format: { type: "string", enum: ["text", "ndjson"], description: "Output format. `ndjson` parses common Linux outputs (df, free, ps aux, ss, lsblk) into newline-delimited JSON." },
2908
3460
  stdin: { type: "string", description: "Optional data piped to the remote process stdin." },
2909
3461
  cwd: { type: "string", description: "Working directory on the remote (cd <cwd> on Linux, Set-Location on Windows)." },
2910
3462
  shell: { type: "string", enum: ["auto", "bash", "powershell"], description: `Override shell selection (default "auto" uses the server's os_type).` },
2911
3463
  parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
2912
- timeout: { type: "number", description: "Per-server timeout in milliseconds (default: 60000)" }
2913
- },
2914
- required: ["command"]
3464
+ timeout: { type: "number", description: "Wall timeout in milliseconds (default: 60000). Usually idleTimeoutSeconds is what you want instead." },
3465
+ 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".' }
3466
+ }
2915
3467
  }
2916
3468
  },
2917
3469
  {
@@ -2950,65 +3502,73 @@ var TOOLS = [
2950
3502
  },
2951
3503
  {
2952
3504
  name: "sftp-write",
2953
- 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.",
3505
+ 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.",
2954
3506
  inputSchema: {
2955
3507
  type: "object",
2956
3508
  properties: {
2957
- serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
2958
- bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
2959
- path: { type: "string", description: "Remote file path (SSH) or object key (R2)" },
2960
- content: { type: "string", description: "Inline UTF-8 file content (mutually exclusive with sourcePath)" },
2961
- sourcePath: { type: "string", description: "Absolute local file path to upload (mutually exclusive with content). Use for files >1 MB or any binary." },
2962
- mode: { oneOf: [{ type: "number" }, { type: "string" }], description: 'POSIX file permission bits (SSH only). Number (0o755 / 493) or octal string ("755" / "0o755"). Default 0o644.' },
2963
- mtime: { oneOf: [{ type: "number" }, { type: "string" }], description: "File modification time (SSH only). ms-since-epoch number or ISO-8601 date string." }
3509
+ serverId: { type: "string", description: "TARGET: UUID of the SSH server (mutually exclusive with bucket)" },
3510
+ bucket: { type: "string", description: "TARGET: Cloudflare R2 bucket name (mutually exclusive with serverId)" },
3511
+ path: { type: "string", description: "TARGET path (SSH file path or R2 object key)" },
3512
+ content: { type: "string", description: "Source: inline UTF-8 file content (mutually exclusive with sourcePath / sourceRemote)" },
3513
+ sourcePath: { type: "string", description: "Source: absolute local file path (mutually exclusive with content / sourceRemote). Use for >1 MB or binary." },
3514
+ 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" } } },
3515
+ mode: { oneOf: [{ type: "number" }, { type: "string" }], description: 'POSIX file permission bits (SSH target only). Number (0o755 / 493) or octal string ("755" / "0o755"). Default 0o644.' },
3516
+ mtime: { oneOf: [{ type: "number" }, { type: "string" }], description: "File modification time (SSH target only). ms-since-epoch number or ISO-8601 date string." }
2964
3517
  },
2965
3518
  required: ["path"]
2966
3519
  }
2967
3520
  },
2968
3521
  {
2969
3522
  name: "sftp-delete",
2970
- 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).",
3523
+ 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.',
2971
3524
  inputSchema: {
2972
3525
  type: "object",
2973
3526
  properties: {
2974
3527
  serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
2975
3528
  bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
2976
3529
  path: { type: "string", description: "File path / object key (or directory / key prefix when recursive)" },
2977
- recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." }
3530
+ recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." },
3531
+ dryRun: { type: "boolean", description: "Preview-only mode: returns what would be deleted (count, total size, sample paths) without touching anything." },
3532
+ confirmAbove: { type: "number", description: "Refuse recursive deletes that touch more than N items unless `confirmCount` matches the actual count." },
3533
+ confirmCount: { type: "number", description: "When confirmAbove gates a delete, pass the exact item count from the dry-run output to confirm intent." }
2978
3534
  },
2979
3535
  required: ["path"]
2980
3536
  }
2981
3537
  },
2982
3538
  {
2983
3539
  name: "docker-list",
2984
- 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`.",
3540
+ 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.',
2985
3541
  inputSchema: {
2986
3542
  type: "object",
2987
3543
  properties: {
2988
- serverId: { type: "string", description: "UUID of the SSH server" },
2989
- format: { type: "string", enum: ["table", "json"], description: "Output format: human table (default) or NDJSON (one JSON object per line, includes labels)." },
3544
+ serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
3545
+ serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to query in parallel. Mutually exclusive with serverId." },
3546
+ 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.' },
2990
3547
  composeOnly: { type: "boolean", description: "Only show containers that have a docker-compose project label." },
2991
3548
  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." },
2992
- groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header." }
2993
- },
2994
- required: ["serverId"]
3549
+ groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header (single-server mode only)." },
3550
+ parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
3551
+ noCache: { type: "boolean", description: "Bypass the 30s in-memory cache." }
3552
+ }
2995
3553
  }
2996
3554
  },
2997
3555
  {
2998
3556
  name: "docker-logs",
2999
- 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.',
3557
+ 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.',
3000
3558
  inputSchema: {
3001
3559
  type: "object",
3002
3560
  properties: {
3003
- serverId: { type: "string", description: "UUID of the SSH server" },
3561
+ serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
3562
+ serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to tail in parallel. Mutually exclusive with serverId." },
3004
3563
  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]." },
3005
- tail: { type: "number", description: "Number of recent log lines per container (default 100). Alias: `lines`." },
3564
+ tail: { type: "number", description: "Number of recent log lines per container (default 100, default 0 in follow mode)." },
3006
3565
  lines: { type: "number", description: "Deprecated alias for `tail`. Prefer `tail`." },
3007
3566
  since: { type: "string", description: 'Time window, e.g. "10m", "2h", "24h", or an absolute "2026-05-09T10:00:00".' },
3008
3567
  grep: { type: "string", description: "Case-insensitive regex/literal filter applied server-side (saves tokens for noisy containers)." },
3009
- 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
+ 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." },
3569
+ parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." }
3010
3570
  },
3011
- required: ["serverId", "containerName"]
3571
+ required: ["containerName"]
3012
3572
  }
3013
3573
  },
3014
3574
  {
@@ -3046,6 +3606,24 @@ var TOOLS = [
3046
3606
  required: ["serverId", "projectPath", "action"]
3047
3607
  }
3048
3608
  },
3609
+ {
3610
+ name: "wait-for",
3611
+ 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.',
3612
+ inputSchema: {
3613
+ type: "object",
3614
+ properties: {
3615
+ type: { type: "string", enum: ["url", "container-health", "file-exists", "sftp-exists"], description: "Condition type to wait on." },
3616
+ target: { type: "string", description: "Target identifier: URL, container name, file path, or R2 key (depends on type)." },
3617
+ 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.' },
3618
+ serverId: { type: "string", description: "SSH server UUID (required for container-health and file-exists)." },
3619
+ bucket: { type: "string", description: "R2 bucket name (used with sftp-exists when checking an R2 object)." },
3620
+ path: { type: "string", description: "Alias for `target` for file-exists/sftp-exists." },
3621
+ intervalSeconds: { type: "number", description: "Poll interval in seconds (default 3, max 60)." },
3622
+ timeout: { type: "number", description: "Total deadline in seconds (default 120, max 1800)." }
3623
+ },
3624
+ required: ["type", "target"]
3625
+ }
3626
+ },
3049
3627
  {
3050
3628
  name: "db-discover",
3051
3629
  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.",
@@ -3084,15 +3662,20 @@ var TOOLS = [
3084
3662
  },
3085
3663
  {
3086
3664
  name: "db-query",
3087
- description: "Execute a SQL query on a site database. Credentials are auto-discovered from site config files. Destructive operations (DROP, TRUNCATE) are blocked.",
3665
+ 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.',
3088
3666
  inputSchema: {
3089
3667
  type: "object",
3090
3668
  properties: {
3091
3669
  serverId: { type: "string", description: "UUID of the SSH server" },
3092
- sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com)" },
3093
- query: { type: "string", description: "SQL query to execute" }
3670
+ query: { type: "string", description: "SQL query to execute" },
3671
+ sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
3672
+ containerName: { type: "string", description: 'Docker container running the DB server (e.g. "supabase-db", "trigger-postgres"). Activates direct-query mode.' },
3673
+ engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
3674
+ dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
3675
+ dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
3676
+ dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." }
3094
3677
  },
3095
- required: ["serverId", "sitePath", "query"]
3678
+ required: ["serverId", "query"]
3096
3679
  }
3097
3680
  },
3098
3681
  {
@@ -3238,9 +3821,21 @@ async function handleCallTool(request) {
3238
3821
  }
3239
3822
  const startTime = Date.now();
3240
3823
  const serverId = a.serverId || a.server_id;
3241
- const result = await executeToolCall(name, a);
3824
+ const serverIds = a.serverIds;
3825
+ const cached = getCachedTool(name, a);
3826
+ let cameFromCache = false;
3827
+ let result;
3828
+ if (cached) {
3829
+ result = cached;
3830
+ cameFromCache = true;
3831
+ } else {
3832
+ result = await executeToolCall(name, a);
3833
+ }
3242
3834
  const durationMs = Date.now() - startTime;
3243
3835
  const isError = result.content?.[0]?.text?.startsWith("Error:");
3836
+ if (!cameFromCache && !isError) {
3837
+ setCachedTool(name, a, result);
3838
+ }
3244
3839
  void writeAuditLog({
3245
3840
  toolName: name,
3246
3841
  arguments: a,
@@ -3249,7 +3844,14 @@ async function handleCallTool(request) {
3249
3844
  errorMessage: isError ? result.content?.[0]?.text : void 0,
3250
3845
  durationMs
3251
3846
  });
3252
- return result;
3847
+ const serverIdLabel = await resolveServerLabel(serverId, serverIds);
3848
+ return postprocessResult(result, {
3849
+ startedAtMs: startTime,
3850
+ serverIdLabel,
3851
+ toolName: name,
3852
+ args: a,
3853
+ cached: cameFromCache
3854
+ });
3253
3855
  }
3254
3856
  async function executeToolCall(name, a, _serverId) {
3255
3857
  const ctx = authContext;
@@ -3265,6 +3867,7 @@ async function executeToolCall(name, a, _serverId) {
3265
3867
  if (error) throw new Error(error.message);
3266
3868
  const includeStats = a.includeStats === true;
3267
3869
  const servers = data || [];
3870
+ rememberServers(servers.map((s) => ({ id: s.id, name: s.name })));
3268
3871
  let statsByServer = /* @__PURE__ */ new Map();
3269
3872
  if (includeStats && servers.length > 0) {
3270
3873
  const parallelism = Math.max(1, Math.min(20, Number(a.statsParallelism) || 8));
@@ -3315,17 +3918,34 @@ async function executeToolCall(name, a, _serverId) {
3315
3918
  if (!st.reachable) return `${base} UNREACHABLE (${st.error || "unknown"})`;
3316
3919
  return `${base} containers:${st.containers} disk_free:${st.diskFreeHuman} uptime:${st.uptimeHuman}`;
3317
3920
  });
3318
- return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No servers found" }] };
3921
+ const contextHints = a.context !== false ? buildServerContextHints(servers) : [];
3922
+ const out = lines.length ? lines.join("\n") : "No servers found";
3923
+ return { content: [{ type: "text", text: contextHints.length ? `${out}
3924
+
3925
+ --- context ---
3926
+ ${contextHints.join("\n")}` : out }] };
3319
3927
  }
3320
3928
  // ----- SSH -----
3321
3929
  case "ssh-execute": {
3322
- const command = String(a.command);
3323
- assertSafeCommand(command);
3930
+ const pipeline = Array.isArray(a.pipeline) ? a.pipeline.map(String).filter(Boolean) : [];
3931
+ const command = pipeline.length > 0 ? "" : String(a.command || "");
3932
+ if (!command && pipeline.length === 0) {
3933
+ return { content: [{ type: "text", text: "Error: pass either `command` or `pipeline[]`" }] };
3934
+ }
3935
+ if (command) assertSafeCommand(command);
3936
+ for (const c of pipeline) assertSafeCommand(c);
3324
3937
  const args2 = Array.isArray(a.args) ? a.args.map(String) : void 0;
3325
3938
  const stdin = typeof a.stdin === "string" ? a.stdin : void 0;
3326
3939
  const shellOverride = typeof a.shell === "string" ? a.shell : "auto";
3327
3940
  const cwd = typeof a.cwd === "string" && a.cwd ? a.cwd : void 0;
3328
3941
  const timeoutMs = a.timeout ? Number(a.timeout) : void 0;
3942
+ const rawIdle = a.idleTimeoutSeconds !== void 0 ? Number(a.idleTimeoutSeconds) : 120;
3943
+ const idleTimeoutMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle * 1e3 : void 0;
3944
+ const repeat = Math.max(1, Math.min(60, Number(a.repeat) || 1));
3945
+ const intervalSeconds = Math.max(0, Math.min(600, Number(a.intervalSeconds) || 0));
3946
+ const repeatOutput = a.output === "diff" || a.output === "last" ? a.output : "all";
3947
+ const format = a.format === "ndjson" ? "ndjson" : "text";
3948
+ const pipelineStopOnError = a.pipelineStopOnError === true;
3329
3949
  const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
3330
3950
  if (targetIds.length === 0) {
3331
3951
  return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
@@ -3336,44 +3956,88 @@ async function executeToolCall(name, a, _serverId) {
3336
3956
  if (timeoutMs) conn.timeout = timeoutMs;
3337
3957
  const shell = shellOverride === "auto" ? os === "windows" ? "powershell" : "bash" : shellOverride;
3338
3958
  let body;
3339
- if (args2 && args2.length > 0) {
3959
+ let pipelineMarker = null;
3960
+ if (pipeline.length > 0) {
3961
+ pipelineMarker = `__MCP_BREAK_${randomBytes(8).toString("hex")}__`;
3962
+ body = buildPipelineScript(pipeline, shell, pipelineMarker, pipelineStopOnError);
3963
+ } else if (args2 && args2.length > 0) {
3340
3964
  body = shell === "powershell" ? buildPowerShellEncodedCommand(command, args2) : buildPosixCommand(command, args2);
3341
3965
  } else if (shell === "powershell" && !/^powershell\b/i.test(command.trim())) {
3342
3966
  body = buildPowerShellEncodedCommand(command, []);
3343
3967
  } else {
3344
3968
  body = command;
3345
3969
  }
3346
- let finalCmd = body;
3347
- if (cwd) {
3348
- if (shell === "bash") {
3349
- finalCmd = `cd ${posixQuote(cwd)} && ${body}`;
3350
- } else {
3351
- const ecMatch = body.match(/-EncodedCommand\s+(\S+)$/);
3352
- if (ecMatch) {
3353
- const decoded = Buffer.from(ecMatch[1], "base64").toString("utf16le");
3354
- const wrapped = `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${decoded}`;
3355
- const reencoded = Buffer.from(wrapped, "utf16le").toString("base64");
3356
- finalCmd = body.replace(/-EncodedCommand\s+\S+$/, `-EncodedCommand ${reencoded}`);
3970
+ const applyCwd = (rawBody) => {
3971
+ if (!cwd) return rawBody;
3972
+ if (shell === "bash") return `cd ${posixQuote(cwd)} && ${rawBody}`;
3973
+ const ecMatch = rawBody.match(/-EncodedCommand\s+(\S+)$/);
3974
+ if (!ecMatch) return rawBody;
3975
+ const decoded = Buffer.from(ecMatch[1], "base64").toString("utf16le");
3976
+ const wrapped = `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${decoded}`;
3977
+ const reencoded = Buffer.from(wrapped, "utf16le").toString("base64");
3978
+ return rawBody.replace(/-EncodedCommand\s+\S+$/, `-EncodedCommand ${reencoded}`);
3979
+ };
3980
+ const finalCmd = applyCwd(body);
3981
+ if (repeat > 1) {
3982
+ const iterations = [];
3983
+ for (let i = 0; i < repeat; i++) {
3984
+ const startedAt = Date.now();
3985
+ const r = await sshExec(conn, finalCmd, proxy, {
3986
+ ...stdin !== void 0 ? { stdin } : {},
3987
+ idleTimeoutMs
3988
+ });
3989
+ iterations.push({ index: i, startedAt, durationMs: Date.now() - startedAt, result: r });
3990
+ if (i < repeat - 1 && intervalSeconds > 0) {
3991
+ await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
3357
3992
  }
3358
3993
  }
3994
+ const last = iterations[iterations.length - 1].result;
3995
+ let serverName2 = serverId;
3996
+ try {
3997
+ const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
3998
+ if (data?.name) serverName2 = data.name;
3999
+ } catch {
4000
+ }
4001
+ return { serverId, serverName: serverName2, os, shell, result: last, iterations };
4002
+ }
4003
+ const result = await sshExec(conn, finalCmd, proxy, {
4004
+ ...stdin !== void 0 ? { stdin } : {},
4005
+ idleTimeoutMs
4006
+ });
4007
+ let pipelineSegments;
4008
+ if (pipelineMarker && pipeline.length > 0) {
4009
+ pipelineSegments = parsePipelineOutput(result.stdout, pipeline, pipelineMarker);
3359
4010
  }
3360
- const result = await sshExec(conn, finalCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
3361
4011
  let serverName = serverId;
3362
4012
  try {
3363
- const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).single();
4013
+ const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
3364
4014
  if (data?.name) serverName = data.name;
3365
4015
  } catch {
3366
4016
  }
3367
- return { serverId, serverName, os, shell, result };
4017
+ return { serverId, serverName, os, shell, result, pipelineSegments };
4018
+ };
4019
+ const renderOne = (r) => {
4020
+ if (r.iterations && r.iterations.length > 0) {
4021
+ return renderRepeatResult(r.iterations, repeatOutput, format);
4022
+ }
4023
+ if (r.pipelineSegments) {
4024
+ return renderPipelineSegments(r.pipelineSegments, r.result, format);
4025
+ }
4026
+ const output = [`Exit code: ${r.result.exitCode} (os: ${r.os}, shell: ${r.shell})`];
4027
+ let stdoutText = r.result.stdout;
4028
+ if (format === "ndjson" && stdoutText) {
4029
+ const parsed = tryParseNdjsonFromCommand(command, stdoutText);
4030
+ if (parsed) stdoutText = parsed;
4031
+ }
4032
+ if (stdoutText) output.push(`--- stdout ---
4033
+ ${stdoutText}`);
4034
+ if (r.result.stderr) output.push(`--- stderr ---
4035
+ ${r.result.stderr}`);
4036
+ return output.join("\n");
3368
4037
  };
3369
4038
  if (targetIds.length === 1) {
3370
- const { os, shell, result } = await runOne(targetIds[0]);
3371
- const output = [`Exit code: ${result.exitCode} (os: ${os}, shell: ${shell})`];
3372
- if (result.stdout) output.push(`--- stdout ---
3373
- ${result.stdout}`);
3374
- if (result.stderr) output.push(`--- stderr ---
3375
- ${result.stderr}`);
3376
- return { content: [{ type: "text", text: output.join("\n") }] };
4039
+ const r = await runOne(targetIds[0]);
4040
+ return { content: [{ type: "text", text: renderOne(r) }] };
3377
4041
  }
3378
4042
  const allResults = [];
3379
4043
  for (let i = 0; i < targetIds.length; i += parallelism) {
@@ -3397,10 +4061,8 @@ ${result.stderr}`);
3397
4061
  if (r.result.exitCode === 0) okCount++;
3398
4062
  else failCount++;
3399
4063
  const header = `=== ${r.serverName} (${r.os}, exit ${r.result.exitCode}) ===`;
3400
- const parts = [header];
3401
- if (r.result.stdout) parts.push(r.result.stdout.trimEnd());
3402
- if (r.result.stderr) parts.push(`[stderr] ${r.result.stderr.trimEnd()}`);
3403
- sections.push(parts.join("\n"));
4064
+ sections.push(`${header}
4065
+ ${renderOne(r)}`);
3404
4066
  }
3405
4067
  sections.push(`--- fan-out summary: ${okCount} ok / ${failCount} failed across ${allResults.length} servers (parallelism ${parallelism}) ---`);
3406
4068
  return { content: [{ type: "text", text: sections.join("\n\n") }] };
@@ -3502,6 +4164,46 @@ ${result.stderr}`);
3502
4164
  }
3503
4165
  case "sftp-write": {
3504
4166
  const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
4167
+ if (a.sourceRemote && typeof a.sourceRemote === "object") {
4168
+ const srcRaw = a.sourceRemote;
4169
+ const srcBucket = typeof srcRaw.bucket === "string" && srcRaw.bucket ? String(srcRaw.bucket) : "";
4170
+ const srcServerId = typeof srcRaw.serverId === "string" && srcRaw.serverId ? String(srcRaw.serverId) : "";
4171
+ const srcPath = String(srcRaw.path || "");
4172
+ if (!srcPath) return { content: [{ type: "text", text: "Error: sourceRemote.path is required" }] };
4173
+ if (!srcBucket && !srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote needs `serverId` or `bucket`" }] };
4174
+ if (srcBucket && srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote can have either `serverId` or `bucket`, not both" }] };
4175
+ const dstPath = String(a.path || "");
4176
+ if (!dstPath) return { content: [{ type: "text", text: "Error: target `path` is required" }] };
4177
+ let srcEp;
4178
+ if (srcBucket) {
4179
+ const k = r2Key(srcPath);
4180
+ if (!k) return { content: [{ type: "text", text: "Error: sourceRemote.path resolves to empty key" }] };
4181
+ srcEp = { kind: "r2", bucket: srcBucket, key: k };
4182
+ } else {
4183
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(srcServerId);
4184
+ srcEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: srcPath };
4185
+ }
4186
+ let dstEp;
4187
+ if (bucket) {
4188
+ const k = r2Key(dstPath);
4189
+ if (!k) return { content: [{ type: "text", text: "Error: target path resolves to empty key" }] };
4190
+ dstEp = { kind: "r2", bucket, key: k };
4191
+ } else if (a.serverId) {
4192
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4193
+ dstEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: dstPath };
4194
+ } else {
4195
+ return { content: [{ type: "text", text: "Error: target needs `serverId` or `bucket`" }] };
4196
+ }
4197
+ try {
4198
+ const { bytes, elapsedMs, mode } = await sftpCopy(srcEp, dstEp);
4199
+ const srcLabel = srcEp.kind === "r2" ? `r2://${srcEp.bucket}/${srcEp.key}` : `ssh:${srcPath}`;
4200
+ const dstLabel = dstEp.kind === "r2" ? `r2://${dstEp.bucket}/${dstEp.key}` : `ssh:${dstPath}`;
4201
+ const mbps = bytes > 0 && elapsedMs > 0 ? bytes / (1024 * 1024) / (elapsedMs / 1e3) : 0;
4202
+ 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)` : ""}` }] };
4203
+ } catch (e) {
4204
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
4205
+ }
4206
+ }
3505
4207
  let fileMode;
3506
4208
  if (typeof a.mode === "number" && Number.isFinite(a.mode)) fileMode = a.mode & 4095;
3507
4209
  else if (typeof a.mode === "string" && a.mode) {
@@ -3564,14 +4266,51 @@ ${result.stderr}`);
3564
4266
  case "sftp-delete": {
3565
4267
  const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
3566
4268
  const recursive = a.recursive === true;
4269
+ const dryRun = a.dryRun === true;
4270
+ const confirmAbove = a.confirmAbove !== void 0 ? Math.max(0, Number(a.confirmAbove)) : void 0;
4271
+ const confirmCount = a.confirmCount !== void 0 ? Number(a.confirmCount) : void 0;
4272
+ const guard = (count, sizeBytes, where) => {
4273
+ if (confirmAbove !== void 0 && count > confirmAbove) {
4274
+ if (confirmCount === count) return null;
4275
+ return {
4276
+ content: [{
4277
+ type: "text",
4278
+ text: `Refusing to delete ${count} item(s) (${formatBytes(sizeBytes)}) under ${where}: exceeds confirmAbove=${confirmAbove}. To proceed, re-issue the call with confirmCount: ${count}.`
4279
+ }]
4280
+ };
4281
+ }
4282
+ return null;
4283
+ };
3567
4284
  if (bucket) {
3568
4285
  const key = r2Key(String(a.path));
3569
4286
  if (!key) return { content: [{ type: "text", text: "Error: path is required" }] };
3570
4287
  try {
3571
4288
  if (recursive) {
4289
+ if (dryRun || confirmAbove !== void 0) {
4290
+ const listed = await r2List(bucket, key, { recursive: true, maxResults: 5e4 });
4291
+ const objects = listed.filter((e) => !e.isPrefix);
4292
+ const totalSize = objects.reduce((s, o) => s + (o.size || 0), 0);
4293
+ if (dryRun) {
4294
+ const sample = objects.slice(0, 5).map((o) => ` - r2://${bucket}/${o.key} (${formatBytes(o.size || 0)})`);
4295
+ return { content: [{ type: "text", text: `[dry-run] Would delete ${objects.length} object(s) totalling ${formatBytes(totalSize)} under r2://${bucket}/${key}${objects.length > 5 ? `
4296
+ ${sample.join("\n")}
4297
+ ... and ${objects.length - 5} more` : objects.length ? `
4298
+ ${sample.join("\n")}` : ""}` }] };
4299
+ }
4300
+ const block = guard(objects.length, totalSize, `r2://${bucket}/${key}`);
4301
+ if (block) return block;
4302
+ }
3572
4303
  const deleted = await r2DeletePrefix(bucket, key);
3573
4304
  return { content: [{ type: "text", text: `Deleted ${deleted} object(s) under r2://${bucket}/${key}` }] };
3574
4305
  }
4306
+ if (dryRun) {
4307
+ try {
4308
+ const head = await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
4309
+ return { content: [{ type: "text", text: `[dry-run] Would delete r2://${bucket}/${key} (${formatBytes(head.ContentLength || 0)})` }] };
4310
+ } catch {
4311
+ return { content: [{ type: "text", text: `[dry-run] r2://${bucket}/${key} not found (delete would 404)` }] };
4312
+ }
4313
+ }
3575
4314
  await r2DeleteObject(bucket, key);
3576
4315
  return { content: [{ type: "text", text: `Deleted r2://${bucket}/${key}` }] };
3577
4316
  } catch (e) {
@@ -3579,6 +4318,25 @@ ${result.stderr}`);
3579
4318
  }
3580
4319
  }
3581
4320
  const { conn, proxy } = await getServerConnection(String(a.serverId));
4321
+ if (recursive && (dryRun || confirmAbove !== void 0)) {
4322
+ const listed = await sftpReaddir(conn, String(a.path), proxy, {
4323
+ recursive: true,
4324
+ maxResults: 5e4
4325
+ });
4326
+ const files = listed.entries.filter((e) => e.kind === "-");
4327
+ const dirs = listed.entries.filter((e) => e.kind === "d");
4328
+ const totalSize = files.reduce((s, f) => s + (f.size || 0), 0);
4329
+ if (dryRun) {
4330
+ const sample = files.slice(0, 5).map((f) => ` - ${f.path} (${formatBytes(f.size || 0)})`);
4331
+ 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 ? `
4332
+ ${sample.join("\n")}${files.length > 5 ? `
4333
+ ... and ${files.length - 5} more` : ""}` : ""}` }] };
4334
+ }
4335
+ const block = guard(files.length + dirs.length, totalSize, String(a.path));
4336
+ if (block) return block;
4337
+ } else if (dryRun) {
4338
+ return { content: [{ type: "text", text: `[dry-run] Would delete ${a.path} (single file/dir; pass recursive: true to preview tree contents)` }] };
4339
+ }
3582
4340
  const result = await sftpDelete(conn, String(a.path), proxy, { recursive });
3583
4341
  return { content: [{ type: "text", text: result }] };
3584
4342
  }
@@ -3588,75 +4346,136 @@ ${result.stderr}`);
3588
4346
  const composeOnly = a.composeOnly === true;
3589
4347
  const groupByProject = a.groupByProject === true;
3590
4348
  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()] : [];
4349
+ const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
4350
+ if (targetIds.length === 0) {
4351
+ return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
4352
+ }
4353
+ const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
3591
4354
  const filterArg = composeOnly ? ` --filter "label=com.docker.compose.project"` : "";
3592
4355
  const cmd = `docker ps -a${filterArg} --format '{{json .}}'`;
3593
- const { conn, proxy } = await getServerConnection(String(a.serverId));
3594
- const result = await sshExec(conn, cmd, proxy);
3595
- if (result.exitCode !== 0) {
3596
- return { content: [{ type: "text", text: `Error: ${result.stderr || result.stdout}` }] };
3597
- }
3598
- const containers = [];
3599
- for (const line of result.stdout.split("\n")) {
3600
- const trimmed = line.trim();
3601
- if (!trimmed) continue;
4356
+ const probeOne = async (serverId) => {
4357
+ let serverName = serverId.slice(0, 8);
3602
4358
  try {
3603
- containers.push(JSON.parse(trimmed));
3604
- } catch {
4359
+ const { conn, proxy } = await getServerConnection(serverId);
4360
+ const r = await sshExec(conn, cmd, proxy);
4361
+ const cached = KNOWN_SERVER_NAMES.get(serverId);
4362
+ if (cached) serverName = cached.name;
4363
+ if (r.exitCode !== 0) {
4364
+ return { serverId, serverName, rows: [], error: (r.stderr || r.stdout || "exit " + r.exitCode).trim().slice(0, 200) };
4365
+ }
4366
+ const rows = [];
4367
+ for (const line of r.stdout.split("\n")) {
4368
+ const t = line.trim();
4369
+ if (!t) continue;
4370
+ try {
4371
+ rows.push(JSON.parse(t));
4372
+ } catch {
4373
+ }
4374
+ }
4375
+ for (const c of rows) {
4376
+ const m = (c.Labels || "").match(/com\.docker\.compose\.project=([^,]+)/);
4377
+ c.Project = m ? m[1] : "";
4378
+ const status = c.Status || "";
4379
+ if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
4380
+ else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
4381
+ else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
4382
+ else if (/^Up\b/i.test(status)) c.Health = "no-check";
4383
+ else c.Health = "";
4384
+ }
4385
+ rememberContainers(serverId, rows.map((r2) => r2.Names || "").filter(Boolean));
4386
+ return { serverId, serverName, rows };
4387
+ } catch (err) {
4388
+ return { serverId, serverName, rows: [], error: err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200) };
3605
4389
  }
4390
+ };
4391
+ const perServer = [];
4392
+ for (let i = 0; i < targetIds.length; i += parallelism) {
4393
+ const batch = targetIds.slice(i, i + parallelism);
4394
+ const chunk = await Promise.all(batch.map(probeOne));
4395
+ perServer.push(...chunk);
3606
4396
  }
3607
- for (const c of containers) {
3608
- const m = (c.Labels || "").match(/com\.docker\.compose\.project=([^,]+)/);
3609
- c.Project = m ? m[1] : "";
3610
- const status = c.Status || "";
3611
- if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
3612
- else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
3613
- else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
3614
- else if (/^Up\b/i.test(status)) c.Health = "no-check";
3615
- else c.Health = "";
3616
- }
3617
- let filtered = containers;
3618
- if (nameFiltersRaw.length > 0) {
4397
+ const applyFilter = (rows) => {
4398
+ if (nameFiltersRaw.length === 0) return rows;
3619
4399
  const matchers = nameFiltersRaw.map((raw) => {
3620
4400
  if (raw.includes("*") || raw.includes("?")) return { kind: "glob", re: globToRegExp(raw) };
3621
4401
  return { kind: "sub", lower: raw.toLowerCase() };
3622
4402
  });
3623
- filtered = containers.filter((c) => {
4403
+ return rows.filter((c) => {
3624
4404
  const name2 = c.Names || "";
3625
4405
  const lower = name2.toLowerCase();
3626
4406
  return matchers.some((m) => m.kind === "glob" ? m.re.test(name2) : lower.includes(m.lower));
3627
4407
  });
3628
- }
3629
- filtered.sort((x, y) => {
4408
+ };
4409
+ const sortRows = (rows) => rows.sort((x, y) => {
3630
4410
  const px = x.Project || "~";
3631
4411
  const py = y.Project || "~";
3632
4412
  const p = px.localeCompare(py);
3633
4413
  return p !== 0 ? p : (x.Names || "").localeCompare(y.Names || "");
3634
4414
  });
4415
+ const fmtRow = (r) => `${(r.Names || "").padEnd(36)} ${(r.Image || "").padEnd(40)} ${(r.Status || "").padEnd(24)} ${(r.Health || "").padEnd(10)} ${(r.Ports || "").padEnd(40)}`;
4416
+ if (targetIds.length === 1) {
4417
+ const only = perServer[0];
4418
+ if (only.error) return { content: [{ type: "text", text: `Error: ${only.error}` }] };
4419
+ const filtered = sortRows(applyFilter(only.rows));
4420
+ if (format === "json") {
4421
+ const text2 = filtered.map((c) => JSON.stringify(c)).join("\n") || "(no containers)";
4422
+ return { content: [{ type: "text", text: text2 }] };
4423
+ }
4424
+ if (groupByProject) {
4425
+ const groups = /* @__PURE__ */ new Map();
4426
+ for (const c of filtered) {
4427
+ const key = c.Project || "(no compose project)";
4428
+ const list = groups.get(key);
4429
+ if (list) list.push(c);
4430
+ else groups.set(key, [c]);
4431
+ }
4432
+ const out = [];
4433
+ for (const [project, rows] of groups) {
4434
+ out.push(`=== ${project} (${rows.length}) ===`);
4435
+ for (const r of rows) out.push(" " + fmtRow(r));
4436
+ out.push("");
4437
+ }
4438
+ return { content: [{ type: "text", text: out.join("\n").trimEnd() || "(no containers)" }] };
4439
+ }
4440
+ const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
4441
+ const body = filtered.map((r) => `${fmtRow(r)} ${r.Project || ""}`);
4442
+ const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
4443
+ return { content: [{ type: "text", text }] };
4444
+ }
3635
4445
  if (format === "json") {
3636
- const text2 = filtered.map((c) => JSON.stringify(c)).join("\n") || "(no containers)";
3637
- return { content: [{ type: "text", text: text2 }] };
4446
+ const lines = [];
4447
+ for (const ps of perServer) {
4448
+ if (ps.error) {
4449
+ lines.push(JSON.stringify({ Server: ps.serverName, Error: ps.error }));
4450
+ continue;
4451
+ }
4452
+ const filtered = sortRows(applyFilter(ps.rows));
4453
+ for (const c of filtered) lines.push(JSON.stringify({ Server: ps.serverName, ...c }));
4454
+ }
4455
+ return { content: [{ type: "text", text: lines.join("\n") || "(no containers)" }] };
3638
4456
  }
3639
- const fmtRow = (r) => `${(r.Names || "").padEnd(36)} ${(r.Image || "").padEnd(40)} ${(r.Status || "").padEnd(24)} ${(r.Health || "").padEnd(10)} ${(r.Ports || "").padEnd(40)}`;
3640
- if (groupByProject) {
3641
- const groups = /* @__PURE__ */ new Map();
3642
- for (const c of filtered) {
3643
- const key = c.Project || "(no compose project)";
3644
- const list = groups.get(key);
3645
- if (list) list.push(c);
3646
- else groups.set(key, [c]);
4457
+ const sections = [];
4458
+ let totalRows = 0;
4459
+ let serversWithRows = 0;
4460
+ for (const ps of perServer) {
4461
+ if (ps.error) {
4462
+ sections.push(`=== ${ps.serverName} === !! ${ps.error}`);
4463
+ continue;
3647
4464
  }
3648
- const out = [];
3649
- for (const [project, rows] of groups) {
3650
- out.push(`=== ${project} (${rows.length}) ===`);
3651
- for (const r of rows) out.push(" " + fmtRow(r));
3652
- out.push("");
4465
+ const filtered = sortRows(applyFilter(ps.rows));
4466
+ if (filtered.length === 0) {
4467
+ sections.push(`=== ${ps.serverName} === (no containers match)`);
4468
+ continue;
3653
4469
  }
3654
- return { content: [{ type: "text", text: out.join("\n").trimEnd() || "(no containers)" }] };
4470
+ serversWithRows++;
4471
+ totalRows += filtered.length;
4472
+ sections.push(`=== ${ps.serverName} (${filtered.length} container(s)) ===`);
4473
+ const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
4474
+ sections.push(header);
4475
+ for (const r of filtered) sections.push(`${fmtRow(r)} ${r.Project || ""}`);
3655
4476
  }
3656
- const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
3657
- const body = filtered.map((r) => `${fmtRow(r)} ${r.Project || ""}`);
3658
- const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
3659
- return { content: [{ type: "text", text }] };
4477
+ sections.push(`--- fan-out summary: ${totalRows} container(s) across ${serversWithRows}/${perServer.length} server(s) ---`);
4478
+ return { content: [{ type: "text", text: sections.join("\n") }] };
3660
4479
  }
3661
4480
  case "docker-logs": {
3662
4481
  const rawNames = Array.isArray(a.containerName) ? a.containerName.map(String) : [String(a.containerName)];
@@ -3674,37 +4493,76 @@ ${result.stderr}`);
3674
4493
  }
3675
4494
  const sinceArg = sinceRaw ? ` --since ${posixQuote(sinceRaw)}` : "";
3676
4495
  const grepSuffix = grepRaw ? ` | grep -i -E ${posixQuote(grepRaw)} --line-buffered` : "";
3677
- const { conn, proxy } = await getServerConnection(String(a.serverId));
4496
+ const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
4497
+ if (targetIds.length === 0) {
4498
+ return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
4499
+ }
4500
+ const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
3678
4501
  const wallSeconds = followSeconds > 0 ? followSeconds : 30;
3679
- conn.timeout = (wallSeconds + 10) * 1e3;
3680
4502
  const followFlag = followSeconds > 0 ? " -f" : "";
3681
- if (containers.length === 1) {
3682
- const c = containers[0];
3683
- const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
3684
- const cmd2 = `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(inner)}`;
3685
- const result2 = await sshExec(conn, cmd2, proxy);
3686
- const acceptable2 = result2.exitCode === 0 || result2.exitCode === 124 || result2.exitCode === 130 || result2.exitCode === 143 || !!grepRaw && result2.exitCode === 1;
3687
- if (!acceptable2 && !result2.stdout) {
3688
- return { content: [{ type: "text", text: `Error (exit ${result2.exitCode}): ${result2.stderr || "(no output)"}` }] };
4503
+ const buildCmd = () => {
4504
+ if (containers.length === 1) {
4505
+ const c = containers[0];
4506
+ const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
4507
+ return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(inner)}`;
3689
4508
  }
3690
- const note2 = result2.exitCode === 124 ? `
4509
+ const subShells = containers.map((c) => {
4510
+ const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
4511
+ return `(${inner} | sed -u -e ${posixQuote(`s/^/[${c}] /`)})`;
4512
+ });
4513
+ const innerCmd = subShells.join(" & ") + " & wait";
4514
+ return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(innerCmd)}`;
4515
+ };
4516
+ const cmd = buildCmd();
4517
+ const probeOne = async (serverId) => {
4518
+ const { conn, proxy } = await getServerConnection(serverId);
4519
+ conn.timeout = (wallSeconds + 10) * 1e3;
4520
+ const result = await sshExec(conn, cmd, proxy);
4521
+ const cached = KNOWN_SERVER_NAMES.get(serverId);
4522
+ return { serverId, serverName: cached?.name || serverId.slice(0, 8), result };
4523
+ };
4524
+ const acceptableCode = (code) => code === 0 || code === 124 || code === 130 || code === 143 || !!grepRaw && code === 1;
4525
+ if (targetIds.length === 1) {
4526
+ const r = await probeOne(targetIds[0]);
4527
+ const result = r.result;
4528
+ const combined = `${result.stdout}
4529
+ ${result.stderr}`;
4530
+ const noSuchMatch = /No such container:\s*(\S+)/i.exec(combined);
4531
+ const fuzzyHint = () => {
4532
+ if (!noSuchMatch) return "";
4533
+ const known = getKnownContainers(targetIds[0]);
4534
+ if (!known?.length) return "";
4535
+ const hits = suggestSimilar(noSuchMatch[1], known);
4536
+ return hits.length ? `
4537
+ (hint) Did you mean: ${hits.join(", ")}?` : "";
4538
+ };
4539
+ if (!acceptableCode(result.exitCode) && !result.stdout) {
4540
+ return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}${fuzzyHint()}` }] };
4541
+ }
4542
+ const note = result.exitCode === 124 ? `
3691
4543
  (note: command exceeded ${wallSeconds}s wall budget; partial output)` : "";
3692
- return { content: [{ type: "text", text: (result2.stdout || "(no log lines matched)") + note2 }] };
4544
+ return { content: [{ type: "text", text: (result.stdout || "(no log lines matched)") + note + fuzzyHint() }] };
3693
4545
  }
3694
- const subShells = containers.map((c) => {
3695
- const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
3696
- return `(${inner} | sed -u -e ${posixQuote(`s/^/[${c}] /`)})`;
3697
- });
3698
- const innerCmd = subShells.join(" & ") + " & wait";
3699
- const cmd = `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(innerCmd)}`;
3700
- const result = await sshExec(conn, cmd, proxy);
3701
- const acceptable = result.exitCode === 0 || result.exitCode === 124 || result.exitCode === 130 || result.exitCode === 143 || !!grepRaw && result.exitCode === 1;
3702
- if (!acceptable && !result.stdout) {
3703
- return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}` }] };
4546
+ const perServer = [];
4547
+ for (let i = 0; i < targetIds.length; i += parallelism) {
4548
+ const batch = targetIds.slice(i, i + parallelism);
4549
+ const chunk = await Promise.all(batch.map(probeOne));
4550
+ perServer.push(...chunk);
3704
4551
  }
3705
- const note = result.exitCode === 124 ? `
3706
- (note: one or more containers exceeded ${wallSeconds}s wall budget; partial output)` : "";
3707
- return { content: [{ type: "text", text: (result.stdout || "(no log lines matched)") + note }] };
4552
+ const sections = [];
4553
+ let okCount = 0;
4554
+ for (const ps of perServer) {
4555
+ if (acceptableCode(ps.result.exitCode) || ps.result.stdout) {
4556
+ okCount++;
4557
+ const note = ps.result.exitCode === 124 ? ` (timed out after ${wallSeconds}s; partial)` : "";
4558
+ sections.push(`=== ${ps.serverName}${note} ===`);
4559
+ sections.push(ps.result.stdout || "(no log lines matched)");
4560
+ } else {
4561
+ sections.push(`=== ${ps.serverName} === !! exit ${ps.result.exitCode}: ${ps.result.stderr.trim().slice(0, 200) || "(no output)"}`);
4562
+ }
4563
+ }
4564
+ sections.push(`--- fan-out summary: ${okCount}/${perServer.length} server(s) returned logs ---`);
4565
+ return { content: [{ type: "text", text: sections.join("\n") }] };
3708
4566
  }
3709
4567
  case "docker-exec": {
3710
4568
  const container = String(a.container).replace(/[^a-zA-Z0-9._-]/g, "");
@@ -3729,8 +4587,20 @@ ${result.stderr}`);
3729
4587
  const output = [`Exit code: ${result.exitCode}`];
3730
4588
  if (result.stdout) output.push(`--- stdout ---
3731
4589
  ${result.stdout}`);
3732
- if (result.stderr) output.push(`--- stderr ---
3733
- ${result.stderr}`);
4590
+ if (result.stderr) {
4591
+ let stderrOut = result.stderr;
4592
+ const noSuch = /No such container:\s*(\S+)/i.exec(result.stderr);
4593
+ if (noSuch) {
4594
+ const known = getKnownContainers(String(a.serverId));
4595
+ if (known && known.length) {
4596
+ const hits = suggestSimilar(noSuch[1], known);
4597
+ if (hits.length) stderrOut += `
4598
+ (hint) Did you mean: ${hits.join(", ")}?`;
4599
+ }
4600
+ }
4601
+ output.push(`--- stderr ---
4602
+ ${stderrOut}`);
4603
+ }
3734
4604
  return { content: [{ type: "text", text: output.join("\n") }] };
3735
4605
  }
3736
4606
  case "docker-compose": {
@@ -3782,6 +4652,94 @@ ${result.stderr}`);
3782
4652
  ${result.stderr}`);
3783
4653
  return { content: [{ type: "text", text: output.join("\n") }] };
3784
4654
  }
4655
+ // ----- wait-for: poll until a condition holds (one tool, four types) -----
4656
+ case "wait-for": {
4657
+ const type = String(a.type || "");
4658
+ if (!["url", "container-health", "file-exists", "sftp-exists"].includes(type)) {
4659
+ return { content: [{ type: "text", text: "Error: type must be one of: url, container-health, file-exists, sftp-exists" }] };
4660
+ }
4661
+ const timeoutSeconds = Math.max(1, Math.min(1800, Number(a.timeout) || 120));
4662
+ const intervalSeconds = Math.max(1, Math.min(60, Number(a.intervalSeconds) || 3));
4663
+ const deadline = Date.now() + timeoutSeconds * 1e3;
4664
+ const trail = [];
4665
+ const check = async () => {
4666
+ if (type === "url") {
4667
+ const target = String(a.target || "");
4668
+ if (!target) return { ok: false, observed: "no target url" };
4669
+ const expectStatus = a.until !== void 0 ? Number(a.until) : 200;
4670
+ try {
4671
+ const ctrl = new AbortController();
4672
+ const t = setTimeout(() => ctrl.abort(), 1e4);
4673
+ const res = await fetch(target, { signal: ctrl.signal, redirect: "manual" });
4674
+ clearTimeout(t);
4675
+ const ok2 = res.status === expectStatus || expectStatus === 200 && res.status >= 200 && res.status < 300;
4676
+ return { ok: ok2, observed: `HTTP ${res.status}` };
4677
+ } catch (e) {
4678
+ return { ok: false, observed: e instanceof Error ? e.message : String(e) };
4679
+ }
4680
+ }
4681
+ if (type === "container-health") {
4682
+ const containerName = String(a.target || "").replace(/[^a-zA-Z0-9._-]/g, "");
4683
+ const until = String(a.until || "healthy");
4684
+ if (!containerName) return { ok: false, observed: "no container name" };
4685
+ if (!a.serverId) return { ok: false, observed: "serverId is required for container-health" };
4686
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4687
+ const cmd2 = `docker inspect --format '{{json .State}}' ${containerName} 2>&1`;
4688
+ const r2 = await sshExec(conn2, cmd2, proxy2);
4689
+ if (r2.exitCode !== 0) return { ok: false, observed: r2.stderr.trim().slice(0, 80) || "inspect failed" };
4690
+ try {
4691
+ const state = JSON.parse(r2.stdout.trim());
4692
+ const healthStatus = state.Health?.Status;
4693
+ if (until === "healthy") {
4694
+ return { ok: healthStatus === "healthy", observed: `Status=${state.Status} Health=${healthStatus || "no-check"}` };
4695
+ }
4696
+ return { ok: state.Status === until, observed: `Status=${state.Status}` };
4697
+ } catch (e) {
4698
+ return { ok: false, observed: "parse error: " + (e instanceof Error ? e.message : String(e)) };
4699
+ }
4700
+ }
4701
+ const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
4702
+ const path = String(a.target || a.path || "");
4703
+ if (!path) return { ok: false, observed: "no path" };
4704
+ if (bucket) {
4705
+ try {
4706
+ await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: r2Key(path) }));
4707
+ return { ok: true, observed: `r2://${bucket}/${path} exists` };
4708
+ } catch {
4709
+ return { ok: false, observed: `r2://${bucket}/${path} not found` };
4710
+ }
4711
+ }
4712
+ if (!a.serverId) return { ok: false, observed: "serverId or bucket is required for file-exists" };
4713
+ const { conn, proxy } = await getServerConnection(String(a.serverId));
4714
+ const safe = sanitizePath(path);
4715
+ const cmd = `test -e ${posixQuote(safe)} && echo OK || echo MISSING`;
4716
+ const r = await sshExec(conn, cmd, proxy);
4717
+ const ok = r.stdout.trim() === "OK";
4718
+ return { ok, observed: ok ? `${safe} exists` : `${safe} missing` };
4719
+ };
4720
+ let attempts = 0;
4721
+ let lastObserved = "";
4722
+ while (Date.now() < deadline) {
4723
+ attempts++;
4724
+ const r = await check();
4725
+ lastObserved = r.observed;
4726
+ trail.push(`[${attempts}] ${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)} ${r.ok ? "OK" : ".."} ${r.observed}`);
4727
+ if (r.ok) {
4728
+ return { content: [{ type: "text", text: `Condition met after ${attempts} attempt(s) in ${((Date.now() - (deadline - timeoutSeconds * 1e3)) / 1e3).toFixed(1)}s.
4729
+ Last observation: ${r.observed}
4730
+
4731
+ --- trail ---
4732
+ ${trail.join("\n")}` }] };
4733
+ }
4734
+ if (Date.now() + intervalSeconds * 1e3 >= deadline) break;
4735
+ await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
4736
+ }
4737
+ return { content: [{ type: "text", text: `Timed out after ${timeoutSeconds}s without seeing condition (${attempts} probe(s)).
4738
+ Last observation: ${lastObserved}
4739
+
4740
+ --- trail ---
4741
+ ${trail.join("\n")}` }] };
4742
+ }
3785
4743
  // ----- Database -----
3786
4744
  case "db-discover": {
3787
4745
  const { conn, proxy } = await getServerConnection(String(a.serverId));
@@ -3813,10 +4771,55 @@ ${result.stderr}`);
3813
4771
  }
3814
4772
  case "db-query": {
3815
4773
  const query = String(a.query).trim();
4774
+ if (!query) return { content: [{ type: "text", text: "Error: query is required" }] };
3816
4775
  assertSafeSql(query);
4776
+ const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName.replace(/[^a-zA-Z0-9._-]/g, "") : "";
4777
+ const explicitEngine = a.engine === "mysql" || a.engine === "postgres" || a.engine === "mssql" ? a.engine : null;
4778
+ const engine = explicitEngine || (containerName ? "postgres" : "mysql");
4779
+ if (!containerName && engine === "mysql") {
4780
+ if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
4781
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4782
+ const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
4783
+ return { content: [{ type: "text", text: output2 || "Query executed successfully (no output)" }] };
4784
+ }
4785
+ if (!containerName) {
4786
+ return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
4787
+ }
4788
+ const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName.replace(/[^a-zA-Z0-9_-]/g, "") : "";
4789
+ const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") : "";
3817
4790
  const { conn, proxy } = await getServerConnection(String(a.serverId));
3818
- const output = await execSiteMysql(conn, String(a.sitePath), query, proxy);
3819
- return { content: [{ type: "text", text: output || "Query executed successfully (no output)" }] };
4791
+ let cmd;
4792
+ let stdinPayload;
4793
+ if (engine === "postgres") {
4794
+ const user = dbUser || "postgres";
4795
+ const db = dbName || "postgres";
4796
+ cmd = `docker exec -i ${containerName} psql -U ${user} -d ${db} -P pager=off`;
4797
+ stdinPayload = query.endsWith(";") ? query : `${query};`;
4798
+ } else if (engine === "mssql") {
4799
+ const user = dbUser || "sa";
4800
+ const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
4801
+ if (!dbPass) return { content: [{ type: "text", text: "Error: mssql requires dbPass (sqlcmd has no equivalent of pg trust auth)" }] };
4802
+ const dbArg = dbName ? `-d ${dbName} ` : "";
4803
+ 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`;
4804
+ stdinPayload = query.endsWith(";") ? `${query}
4805
+ GO
4806
+ ` : `${query};
4807
+ GO
4808
+ `;
4809
+ } else {
4810
+ const user = dbUser || "root";
4811
+ const db = dbName ? ` ${dbName}` : "";
4812
+ const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
4813
+ const passArg = dbPass ? `-p${posixQuote(dbPass)} ` : "";
4814
+ cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${db}`;
4815
+ stdinPayload = query.endsWith(";") ? query : `${query};`;
4816
+ }
4817
+ const result = await sshExec(conn, cmd, proxy, { stdin: stdinPayload });
4818
+ const output = result.stdout.trim() || result.stderr.trim() || "(no output)";
4819
+ if (result.exitCode !== 0 && !result.stdout) {
4820
+ return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
4821
+ }
4822
+ return { content: [{ type: "text", text: output }] };
3820
4823
  }
3821
4824
  // ----- Env Config -----
3822
4825
  case "env-list": {