@mgsoftwarebv/mg-dashboard-mcp 3.10.2 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { createClient } from '@supabase/supabase-js';
14
14
  import { readFile, mkdtemp, writeFile, rm } from 'fs/promises';
15
15
  import { tmpdir } from 'os';
16
16
  import { Client } from 'ssh2';
17
- import { ListObjectsV2Command, DeleteObjectsCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand, HeadObjectCommand, GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
17
+ import { HeadObjectCommand, S3Client, ListObjectsV2Command, DeleteObjectsCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand, GetObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
18
18
 
19
19
  var __defProp = Object.defineProperty;
20
20
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -1373,6 +1373,7 @@ var TOOL_MODULE_MAP = {
1373
1373
  "docker-logs": "ssh_servers",
1374
1374
  "docker-exec": "ssh_servers",
1375
1375
  "docker-compose": "ssh_servers",
1376
+ "wait-for": "ssh_servers",
1376
1377
  "db-discover": "ssh_servers",
1377
1378
  "db-tables": "ssh_servers",
1378
1379
  "db-describe": "ssh_servers",
@@ -1861,7 +1862,12 @@ async function getProxyConnection() {
1861
1862
  async function getServerConnection(serverId) {
1862
1863
  assertServerAccess(serverId);
1863
1864
  const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted, allowed_ssh_ips, os_type").eq("id", serverId).single();
1864
- if (error || !data) throw new Error(`Server not found: ${serverId}`);
1865
+ if (error || !data) {
1866
+ const candidates = Array.from(KNOWN_SERVER_NAMES.values()).flatMap((s) => [s.name, s.id]);
1867
+ const hits = suggestSimilar(serverId, candidates);
1868
+ const hint = hits.length ? ` Did you mean: ${hits.join(", ")}?` : "";
1869
+ throw new Error(`Server not found: ${serverId}.${hint}`);
1870
+ }
1865
1871
  if (!encryptionKey) throw new Error("ENCRYPTION_KEY required to decrypt server credentials");
1866
1872
  const conn = {
1867
1873
  hostname: data.hostname,
@@ -1877,42 +1883,73 @@ async function getServerConnection(serverId) {
1877
1883
  return { conn, proxy, os };
1878
1884
  }
1879
1885
  async function sshExec(opts, command, proxy, options) {
1886
+ const first = await sshExecOnce(opts, command, proxy, options);
1887
+ if (options?.noRetry) return first;
1888
+ if (!isTransientSshError(first.stderr, first.exitCode)) return first;
1889
+ await new Promise((r) => setTimeout(r, 1e3));
1890
+ const second = await sshExecOnce(opts, command, proxy, options);
1891
+ if (second.exitCode === -1 && second.stderr) {
1892
+ second.stderr = `[retry-1 also failed] ${second.stderr}`;
1893
+ }
1894
+ return second;
1895
+ }
1896
+ async function sshExecOnce(opts, command, proxy, options) {
1880
1897
  if (proxy) return sshExecViaProxy(proxy, opts, command, options);
1881
1898
  return new Promise((resolve) => {
1882
1899
  const ssh = new Client();
1883
1900
  let stdout = "";
1884
1901
  let stderr = "";
1885
1902
  let done = false;
1886
- const timeout = opts.timeout || 6e4;
1887
- const timer = setTimeout(() => {
1903
+ const wallTimeout = opts.timeout || 6e4;
1904
+ const idleTimeout = options?.idleTimeoutMs;
1905
+ const wallTimer = setTimeout(() => {
1888
1906
  if (!done) {
1889
1907
  done = true;
1908
+ clearTimeout(idleTimer);
1890
1909
  ssh.end();
1891
- resolve({ stdout, stderr, exitCode: -1 });
1910
+ resolve({ stdout, stderr: stderr || `idle/wall timeout after ${wallTimeout}ms`, exitCode: -1 });
1892
1911
  }
1893
- }, timeout);
1912
+ }, wallTimeout);
1913
+ let idleTimer;
1914
+ const armIdle = () => {
1915
+ if (!idleTimeout) return;
1916
+ if (idleTimer) clearTimeout(idleTimer);
1917
+ idleTimer = setTimeout(() => {
1918
+ if (!done) {
1919
+ done = true;
1920
+ clearTimeout(wallTimer);
1921
+ ssh.end();
1922
+ resolve({ stdout, stderr: stderr || `idle timeout after ${idleTimeout}ms with no output`, exitCode: -1 });
1923
+ }
1924
+ }, idleTimeout);
1925
+ };
1926
+ armIdle();
1894
1927
  ssh.on("ready", () => {
1895
1928
  const execOpts = options?.pty ? { pty: true } : {};
1896
1929
  ssh.exec(command, execOpts, (err, stream) => {
1897
1930
  if (err) {
1898
1931
  if (!done) {
1899
1932
  done = true;
1900
- clearTimeout(timer);
1933
+ clearTimeout(wallTimer);
1934
+ if (idleTimer) clearTimeout(idleTimer);
1901
1935
  ssh.end();
1902
- resolve({ stdout, stderr, exitCode: -1 });
1936
+ resolve({ stdout, stderr: err.message || stderr, exitCode: -1 });
1903
1937
  }
1904
1938
  return;
1905
1939
  }
1906
1940
  stream.on("data", (d) => {
1907
1941
  stdout += d.toString();
1942
+ armIdle();
1908
1943
  });
1909
1944
  stream.stderr.on("data", (d) => {
1910
1945
  stderr += d.toString();
1946
+ armIdle();
1911
1947
  });
1912
1948
  stream.on("close", (code) => {
1913
1949
  if (!done) {
1914
1950
  done = true;
1915
- clearTimeout(timer);
1951
+ clearTimeout(wallTimer);
1952
+ if (idleTimer) clearTimeout(idleTimer);
1916
1953
  ssh.end();
1917
1954
  resolve({ stdout, stderr, exitCode: code ?? 0 });
1918
1955
  }
@@ -1925,7 +1962,8 @@ async function sshExec(opts, command, proxy, options) {
1925
1962
  ssh.on("error", (err) => {
1926
1963
  if (!done) {
1927
1964
  done = true;
1928
- clearTimeout(timer);
1965
+ clearTimeout(wallTimer);
1966
+ if (idleTimer) clearTimeout(idleTimer);
1929
1967
  resolve({ stdout, stderr: err.message, exitCode: -1 });
1930
1968
  }
1931
1969
  });
@@ -1936,7 +1974,7 @@ async function sshExec(opts, command, proxy, options) {
1936
1974
  password: opts.password,
1937
1975
  privateKey: opts.privateKey,
1938
1976
  passphrase: opts.passphrase,
1939
- readyTimeout: timeout
1977
+ readyTimeout: wallTimeout
1940
1978
  });
1941
1979
  });
1942
1980
  }
@@ -1944,16 +1982,35 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
1944
1982
  return new Promise((resolve) => {
1945
1983
  const proxyClient = new Client();
1946
1984
  let done = false;
1947
- const timeout = targetOpts.timeout || 6e4;
1948
- const timer = setTimeout(() => {
1985
+ const wallTimeout = targetOpts.timeout || 6e4;
1986
+ const idleTimeout = options?.idleTimeoutMs;
1987
+ const wallTimer = setTimeout(() => {
1949
1988
  if (!done) {
1950
1989
  done = true;
1990
+ clearTimeout(idleTimer);
1951
1991
  proxyClient.end();
1952
1992
  resolve({ stdout: "", stderr: "SSH proxy command timeout", exitCode: -1 });
1953
1993
  }
1954
- }, timeout);
1994
+ }, wallTimeout);
1995
+ let idleTimer;
1996
+ let stdoutBuf = "";
1997
+ let stderrBuf = "";
1998
+ const armIdle = () => {
1999
+ if (!idleTimeout) return;
2000
+ if (idleTimer) clearTimeout(idleTimer);
2001
+ idleTimer = setTimeout(() => {
2002
+ if (!done) {
2003
+ done = true;
2004
+ clearTimeout(wallTimer);
2005
+ proxyClient.end();
2006
+ resolve({ stdout: stdoutBuf, stderr: stderrBuf || `idle timeout after ${idleTimeout}ms with no output`, exitCode: -1 });
2007
+ }
2008
+ }, idleTimeout);
2009
+ };
2010
+ armIdle();
1955
2011
  const cleanup = () => {
1956
- clearTimeout(timer);
2012
+ clearTimeout(wallTimer);
2013
+ if (idleTimer) clearTimeout(idleTimer);
1957
2014
  proxyClient.end();
1958
2015
  };
1959
2016
  proxyClient.on("ready", () => {
@@ -1967,8 +2024,6 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
1967
2024
  return;
1968
2025
  }
1969
2026
  const targetClient = new Client();
1970
- let stdout = "";
1971
- let stderr = "";
1972
2027
  targetClient.on("ready", () => {
1973
2028
  const execOpts = options?.pty ? { pty: true } : {};
1974
2029
  targetClient.exec(command, execOpts, (execErr, stream) => {
@@ -1977,22 +2032,24 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
1977
2032
  done = true;
1978
2033
  targetClient.end();
1979
2034
  cleanup();
1980
- resolve({ stdout, stderr, exitCode: -1 });
2035
+ resolve({ stdout: stdoutBuf, stderr: stderrBuf, exitCode: -1 });
1981
2036
  }
1982
2037
  return;
1983
2038
  }
1984
2039
  stream.on("data", (d) => {
1985
- stdout += d.toString();
2040
+ stdoutBuf += d.toString();
2041
+ armIdle();
1986
2042
  });
1987
2043
  stream.stderr.on("data", (d) => {
1988
- stderr += d.toString();
2044
+ stderrBuf += d.toString();
2045
+ armIdle();
1989
2046
  });
1990
2047
  stream.on("close", (code) => {
1991
2048
  if (!done) {
1992
2049
  done = true;
1993
2050
  targetClient.end();
1994
2051
  cleanup();
1995
- resolve({ stdout, stderr, exitCode: code ?? 0 });
2052
+ resolve({ stdout: stdoutBuf, stderr: stderrBuf, exitCode: code ?? 0 });
1996
2053
  }
1997
2054
  });
1998
2055
  if (options?.stdin !== void 0) {
@@ -2005,7 +2062,7 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
2005
2062
  done = true;
2006
2063
  targetClient.end();
2007
2064
  cleanup();
2008
- resolve({ stdout, stderr: targetErr.message, exitCode: -1 });
2065
+ resolve({ stdout: stdoutBuf, stderr: targetErr.message, exitCode: -1 });
2009
2066
  }
2010
2067
  });
2011
2068
  targetClient.connect({
@@ -2014,7 +2071,7 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
2014
2071
  password: targetOpts.password,
2015
2072
  privateKey: targetOpts.privateKey,
2016
2073
  passphrase: targetOpts.passphrase,
2017
- readyTimeout: timeout
2074
+ readyTimeout: wallTimeout
2018
2075
  });
2019
2076
  });
2020
2077
  });
@@ -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,368 @@ 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 cols = header.trim().split(/\s+/);
2883
+ const out = [];
2884
+ for (const ln of lines.slice(1)) {
2885
+ const parts = splitCols(ln, cols.length);
2886
+ if (parts.length < 4) continue;
2887
+ const obj = {};
2888
+ for (let i = 0; i < cols.length; i++) obj[cols[i]] = parts[i] || "";
2889
+ out.push(JSON.stringify(obj));
2890
+ }
2891
+ return out.length ? out.join("\n") : null;
2892
+ }
2893
+ function parseFree(s) {
2894
+ const lines = s.split("\n").filter(Boolean);
2895
+ const out = [];
2896
+ for (const ln of lines) {
2897
+ if (/^\s*total\s+used\s+free/i.test(ln)) continue;
2898
+ const m = ln.match(/^(\S+):?\s+(.+)$/);
2899
+ if (!m) continue;
2900
+ const [type, rest] = [m[1], m[2]];
2901
+ const cols = rest.trim().split(/\s+/);
2902
+ out.push(JSON.stringify({ type: type.replace(/:$/, ""), values: cols.map(Number) }));
2903
+ }
2904
+ return out.length ? out.join("\n") : null;
2905
+ }
2906
+ function parsePsAux(s) {
2907
+ const lines = s.split("\n").filter(Boolean);
2908
+ if (!lines[0] || !/USER\s+PID/i.test(lines[0])) return null;
2909
+ const headers = lines[0].trim().split(/\s+/);
2910
+ const out = [];
2911
+ for (const ln of lines.slice(1)) {
2912
+ const parts = splitCols(ln, headers.length);
2913
+ if (parts.length < headers.length) continue;
2914
+ const obj = {};
2915
+ for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
2916
+ out.push(JSON.stringify(obj));
2917
+ }
2918
+ return out.length ? out.join("\n") : null;
2919
+ }
2920
+ function parseSs(s) {
2921
+ const lines = s.split("\n").filter(Boolean);
2922
+ if (!lines[0] || !/(State|Netid)/i.test(lines[0])) return null;
2923
+ const headers = lines[0].trim().split(/\s+/);
2924
+ const out = [];
2925
+ for (const ln of lines.slice(1)) {
2926
+ const parts = splitCols(ln, headers.length);
2927
+ const obj = {};
2928
+ for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
2929
+ out.push(JSON.stringify(obj));
2930
+ }
2931
+ return out.length ? out.join("\n") : null;
2932
+ }
2933
+ function parseLsblk(s) {
2934
+ const lines = s.split("\n").filter(Boolean);
2935
+ if (!lines[0] || !/NAME\s+MAJ:MIN/i.test(lines[0])) return null;
2936
+ const headers = lines[0].trim().split(/\s+/);
2937
+ const out = [];
2938
+ for (const ln of lines.slice(1)) {
2939
+ const parts = splitCols(ln, headers.length);
2940
+ const obj = {};
2941
+ for (let i = 0; i < headers.length; i++) obj[headers[i]] = parts[i] || "";
2942
+ out.push(JSON.stringify(obj));
2943
+ }
2944
+ return out.length ? out.join("\n") : null;
2945
+ }
2946
+ async function resolveServerLabel(serverId, serverIds) {
2947
+ if (Array.isArray(serverIds) && serverIds.length > 0) return `${serverIds.length} servers`;
2948
+ if (!serverId) return void 0;
2949
+ const cached = KNOWN_SERVER_NAMES.get(serverId);
2950
+ if (cached) return cached.name;
2951
+ try {
2952
+ const { data } = await supabase.from("ssh_server").select("id, name").eq("id", serverId).maybeSingle();
2953
+ if (data?.name) {
2954
+ KNOWN_SERVER_NAMES.set(serverId, { id: serverId, name: data.name });
2955
+ return data.name;
2956
+ }
2957
+ } catch {
2958
+ }
2959
+ return serverId.slice(0, 8);
2960
+ }
2504
2961
  async function sftpWrite(opts, filePath, input, proxy, meta) {
2505
2962
  const safe = sanitizePath(filePath);
2506
2963
  assertWritablePath(safe);
@@ -2701,6 +3158,92 @@ async function sftpDelete(opts, filePath, proxy, options) {
2701
3158
  return `Error: ${e.message}`;
2702
3159
  }
2703
3160
  }
3161
+ async function sftpCopy(src, dst) {
3162
+ const startedAt = Date.now();
3163
+ if (src.kind === "r2" && dst.kind === "r2") {
3164
+ const { size } = await r2CopyObject(src.bucket, src.key, dst.bucket, dst.key);
3165
+ return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2-server-side" };
3166
+ }
3167
+ if (src.kind === "r2" && dst.kind === "ssh") {
3168
+ const safe = sanitizePath(dst.path);
3169
+ assertWritablePath(safe);
3170
+ const { stream, size } = await r2GetObjectStream(src.bucket, src.key);
3171
+ const { client, cleanup } = await connectSshClient(dst.opts, dst.proxy, 6e4);
3172
+ try {
3173
+ await new Promise((resolve, reject) => {
3174
+ client.sftp((err, sftp) => {
3175
+ if (err) return reject(err);
3176
+ const ws = sftp.createWriteStream(safe);
3177
+ ws.on("close", () => resolve());
3178
+ ws.on("error", reject);
3179
+ stream.on("error", reject);
3180
+ stream.pipe(ws);
3181
+ });
3182
+ });
3183
+ } finally {
3184
+ cleanup();
3185
+ }
3186
+ return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "r2\u2192ssh-stream" };
3187
+ }
3188
+ if (src.kind === "ssh" && dst.kind === "r2") {
3189
+ const safeSrc = sanitizePath(src.path);
3190
+ const { client, cleanup } = await connectSshClient(src.opts, src.proxy, 6e4);
3191
+ try {
3192
+ const { stream, size } = await new Promise((resolve, reject) => {
3193
+ client.sftp((err, sftp) => {
3194
+ if (err) return reject(err);
3195
+ sftp.stat(safeSrc, (statErr, stats) => {
3196
+ if (statErr) return reject(statErr);
3197
+ const s = stats.size || 0;
3198
+ const rs = sftp.createReadStream(safeSrc);
3199
+ resolve({ stream: rs, size: s });
3200
+ });
3201
+ });
3202
+ });
3203
+ if (size > R2_MULTIPART_THRESHOLD) {
3204
+ cleanup();
3205
+ throw new Error(`Source object is ${formatBytes(size)} which exceeds R2 single-PUT cap (${formatBytes(R2_MULTIPART_THRESHOLD)}). For now: download via sftp-write { sourceRemote, target: local } first, then upload to R2 with sourcePath.`);
3206
+ }
3207
+ await r2PutObject(dst.bucket, dst.key, stream, size);
3208
+ return { bytes: size, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192r2-stream" };
3209
+ } finally {
3210
+ cleanup();
3211
+ }
3212
+ }
3213
+ if (src.kind === "ssh" && dst.kind === "ssh") {
3214
+ const safeSrc = sanitizePath(src.path);
3215
+ const safeDst = sanitizePath(dst.path);
3216
+ assertWritablePath(safeDst);
3217
+ const srcConn = await connectSshClient(src.opts, src.proxy, 6e4);
3218
+ let dstConn;
3219
+ try {
3220
+ dstConn = await connectSshClient(dst.opts, dst.proxy, 6e4);
3221
+ const bytes = await new Promise((resolve, reject) => {
3222
+ srcConn.client.sftp((err, srcSftp) => {
3223
+ if (err) return reject(err);
3224
+ dstConn.client.sftp((err2, dstSftp) => {
3225
+ if (err2) return reject(err2);
3226
+ srcSftp.stat(safeSrc, (statErr, stats) => {
3227
+ if (statErr) return reject(statErr);
3228
+ const size = stats.size || 0;
3229
+ const rs = srcSftp.createReadStream(safeSrc);
3230
+ const ws = dstSftp.createWriteStream(safeDst);
3231
+ ws.on("close", () => resolve(size));
3232
+ ws.on("error", reject);
3233
+ rs.on("error", reject);
3234
+ rs.pipe(ws);
3235
+ });
3236
+ });
3237
+ });
3238
+ });
3239
+ return { bytes, elapsedMs: Date.now() - startedAt, mode: "ssh\u2192ssh-stream" };
3240
+ } finally {
3241
+ srcConn.cleanup();
3242
+ dstConn?.cleanup();
3243
+ }
3244
+ }
3245
+ throw new Error("Unsupported copy combination");
3246
+ }
2704
3247
  var BLOCKED_COMMANDS = [
2705
3248
  "rm -rf /",
2706
3249
  "rm -fr /",
@@ -2885,33 +3428,41 @@ async function mijnhostFetch(path, options = {}) {
2885
3428
  var TOOLS = [
2886
3429
  {
2887
3430
  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).",
3431
+ description: 'List all SSH servers you have access to. Returns id, name, hostname, tags, and os_type per server. Pass `includeStats: true` to also probe each server in parallel for container count, disk-free, and uptime (skips unreachable hosts gracefully). Cached for 60s \u2014 pass `noCache: true` to force a refresh. Adds an "auto-context" footer with one-line tags inferred from server name/tags so the LLM knows which host runs Trigger / Supabase / proxy / etc. (disable with `context: false`).',
2889
3432
  inputSchema: {
2890
3433
  type: "object",
2891
3434
  properties: {
2892
3435
  includeStats: { type: "boolean", description: "When true, probe each server (in parallel, with timeout) for container count, disk-free, and uptime." },
2893
3436
  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)." }
3437
+ statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." },
3438
+ context: { type: "boolean", description: "Include auto-context footer with name/tag-based hints (default true). Pass false to suppress." },
3439
+ noCache: { type: "boolean", description: "Bypass the 60s in-memory cache." }
2895
3440
  }
2896
3441
  }
2897
3442
  },
2898
3443
  {
2899
3444
  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.',
3445
+ description: 'Execute one or more commands on a remote server via SSH. OS-aware: bash on linux, `powershell -EncodedCommand` (UTF-16LE base64) on windows, so $, #, quotes, spaces inside `args` are never re-interpreted by a shell. Some dangerous commands are blocked. Use `list-servers` first to see each server\'s os_type.\n\nThree modes (compose them in one call):\n- Single: `command` (+ optional `args[]`). Quick or safe.\n- Pipeline: `pipeline: ["cmd1","cmd2","cmd3"]` runs all commands sequentially in one SSH session and returns segmented per-command output (each with its own exit code). Default continues on error; pass `pipelineStopOnError: true` to abort.\n- Repeat: `repeat: N, intervalSeconds: S, output: "all"|"diff"|"last"`. Diff mode shows only added/removed lines vs. the previous run \u2014 perfect for "watch this metric for 5 min".\n\n`stdin` pipes data into the remote process (queries, scripts, secrets) without putting it on the command line. `cwd` sets the working directory (auto `cd` on Linux / `Set-Location` on Windows).\n\nIdle timeout: defaults to 120s \u2014 if the remote produces no stdout/stderr for that long the call returns with a clear timeout message. Pass `idleTimeoutSeconds: 0` to disable, or e.g. `300` for slow workloads.\n\nFan-out: pass `serverIds: [id1, id2, ...]` (instead of `serverId`) to run across multiple servers in parallel.\n\nAuto-features: outputs >8 KB are auto-truncated with a continuation hint; transient SSH failures (ECONNRESET/ETIMEDOUT/etc) get one automatic retry. Set `format: "ndjson"` to parse common Linux outputs (df / free / ps aux / ss / lsblk) into newline-delimited JSON instead of whitespace-padded text.',
2901
3446
  inputSchema: {
2902
3447
  type: "object",
2903
3448
  properties: {
2904
3449
  serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
2905
3450
  serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to run the command on in parallel. Mutually exclusive with serverId." },
2906
- command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required.' },
3451
+ command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required unless `pipeline[]` is set.' },
2907
3452
  args: { type: "array", items: { type: "string" }, description: "Optional argv list. When provided, each entry is safely quoted/encoded for the target OS." },
3453
+ pipeline: { type: "array", items: { type: "string" }, description: "Run multiple shell commands sequentially in ONE SSH session, with per-command exit codes and segmented output. Saves N round-trips for setup\u2192test\u2192cleanup workflows." },
3454
+ pipelineStopOnError: { type: "boolean", description: "When pipeline is set, abort the chain on the first non-zero exit (default false: keep going so all outputs reach the LLM)." },
3455
+ repeat: { type: "number", description: 'Re-run the command/pipeline N times (default 1, max 60). Combine with intervalSeconds + output to "watch" a metric.' },
3456
+ intervalSeconds: { type: "number", description: "When repeat>1, seconds to wait between iterations (default 0, max 600)." },
3457
+ output: { type: "string", enum: ["all", "diff", "last"], description: "When repeat>1: how to render iterations. `all` = full output per run; `diff` = only added/removed lines vs. previous run; `last` = only the final iteration." },
3458
+ format: { type: "string", enum: ["text", "ndjson"], description: "Output format. `ndjson` parses common Linux outputs (df, free, ps aux, ss, lsblk) into newline-delimited JSON." },
2908
3459
  stdin: { type: "string", description: "Optional data piped to the remote process stdin." },
2909
3460
  cwd: { type: "string", description: "Working directory on the remote (cd <cwd> on Linux, Set-Location on Windows)." },
2910
3461
  shell: { type: "string", enum: ["auto", "bash", "powershell"], description: `Override shell selection (default "auto" uses the server's os_type).` },
2911
3462
  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"]
3463
+ timeout: { type: "number", description: "Wall timeout in milliseconds (default: 60000). Usually idleTimeoutSeconds is what you want instead." },
3464
+ idleTimeoutSeconds: { type: "number", description: 'Abort if no output for N seconds (default 120). Pass 0 to disable. Useful when the wall budget is hard to predict but silence usually means "stuck".' }
3465
+ }
2915
3466
  }
2916
3467
  },
2917
3468
  {
@@ -2950,65 +3501,73 @@ var TOOLS = [
2950
3501
  },
2951
3502
  {
2952
3503
  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.",
3504
+ description: "Write OR copy a file in remote storage. Two target backends (`serverId` or `bucket`) and three source modes:\n\nSources:\n- `content` (string): inline UTF-8 text (\u22481 MB max).\n- `sourcePath` (absolute local path): streams a local file. SSH uses ssh2 fastPut; R2 uses streamed PUT (or automatic multipart for >4.5 GB). Handles GB-scale.\n- `sourceRemote: { serverId|bucket, path }`: COPY mode \u2014 pulls from one remote and writes to another in a single operation, no local detour. Supports all four directions: ssh\u2194ssh, ssh\u2194r2, r2\u2194ssh, r2\u2194r2 (R2\u2194R2 uses server-side CopyObject = zero data egress).\n\nTargets:\n- `serverId`: SSH server via SFTP. Protected system paths blocked. Optional `mode` (POSIX bits e.g. 0o755) and `mtime` (modification time) \u2014 SSH only.\n- `bucket`: Cloudflare R2 bucket. `mode`/`mtime` are ignored on R2.",
2954
3505
  inputSchema: {
2955
3506
  type: "object",
2956
3507
  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." }
3508
+ serverId: { type: "string", description: "TARGET: UUID of the SSH server (mutually exclusive with bucket)" },
3509
+ bucket: { type: "string", description: "TARGET: Cloudflare R2 bucket name (mutually exclusive with serverId)" },
3510
+ path: { type: "string", description: "TARGET path (SSH file path or R2 object key)" },
3511
+ content: { type: "string", description: "Source: inline UTF-8 file content (mutually exclusive with sourcePath / sourceRemote)" },
3512
+ sourcePath: { type: "string", description: "Source: absolute local file path (mutually exclusive with content / sourceRemote). Use for >1 MB or binary." },
3513
+ sourceRemote: { type: "object", description: "Source: copy from another remote. Shape: { serverId?: string, bucket?: string, path: string }. Picks the streaming strategy (ssh\u2192ssh, r2\u2192ssh, ssh\u2192r2, r2\u2192r2 server-side).", properties: { serverId: { type: "string" }, bucket: { type: "string" }, path: { type: "string" } } },
3514
+ mode: { oneOf: [{ type: "number" }, { type: "string" }], description: 'POSIX file permission bits (SSH target only). Number (0o755 / 493) or octal string ("755" / "0o755"). Default 0o644.' },
3515
+ mtime: { oneOf: [{ type: "number" }, { type: "string" }], description: "File modification time (SSH target only). ms-since-epoch number or ISO-8601 date string." }
2964
3516
  },
2965
3517
  required: ["path"]
2966
3518
  }
2967
3519
  },
2968
3520
  {
2969
3521
  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).",
3522
+ description: 'Delete a file (or directory tree) from remote storage. Two backends (provide exactly one of `serverId` or `bucket`):\n- `serverId`: delete a file or empty directory on an SSH server via SFTP. Protected system paths blocked. With `recursive: true` walks the tree depth-first via SFTP and removes files + empty dirs from the bottom up (works on Windows OpenSSH too \u2014 no shell tricks).\n- `bucket`: delete an object from a Cloudflare R2 bucket. `path` is the object key. With `recursive: true`, treats `path` as a key prefix and batch-deletes every object beneath it.\n\nSafety nets:\n- `dryRun: true` \u2192 returns "Would delete N items (size)" with a sample preview, no actual deletion.\n- `confirmAbove: N` + `confirmCount: <exact>` \u2192 refuses recursive deletes touching more than N items unless the caller passes `confirmCount` matching the actual count.',
2971
3523
  inputSchema: {
2972
3524
  type: "object",
2973
3525
  properties: {
2974
3526
  serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
2975
3527
  bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
2976
3528
  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)." }
3529
+ recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." },
3530
+ dryRun: { type: "boolean", description: "Preview-only mode: returns what would be deleted (count, total size, sample paths) without touching anything." },
3531
+ confirmAbove: { type: "number", description: "Refuse recursive deletes that touch more than N items unless `confirmCount` matches the actual count." },
3532
+ confirmCount: { type: "number", description: "When confirmAbove gates a delete, pass the exact item count from the dry-run output to confirm intent." }
2978
3533
  },
2979
3534
  required: ["path"]
2980
3535
  }
2981
3536
  },
2982
3537
  {
2983
3538
  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`.",
3539
+ description: 'List Docker containers on one or more remote servers. Sorted by compose project then name, with a HEALTH column (healthy / unhealthy / starting / no-check). Filter by `nameFilter` (string OR array of substring/glob entries \u2014 passes if ANY entry matches), restrict to compose containers with `composeOnly`, or group output per project with `groupByProject`.\n\nFan-out: pass `serverIds: [...]` (instead of `serverId`) to query multiple servers in parallel \u2014 perfect for "show all unhealthy containers across the fleet". Per-server sections are clearly headed.\n\nCached for 30s \u2014 pass `noCache: true` to bypass.',
2985
3540
  inputSchema: {
2986
3541
  type: "object",
2987
3542
  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)." },
3543
+ serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
3544
+ serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to query in parallel. Mutually exclusive with serverId." },
3545
+ format: { type: "string", enum: ["table", "json"], description: 'Output format: human table (default) or NDJSON. JSON adds a "Server" field per row in fan-out mode.' },
2990
3546
  composeOnly: { type: "boolean", description: "Only show containers that have a docker-compose project label." },
2991
3547
  nameFilter: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Filter by container name. Single entry or array; each entry is glob (* or ?) or substring. A row passes when ANY entry matches." },
2992
- groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header." }
2993
- },
2994
- required: ["serverId"]
3548
+ groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header (single-server mode only)." },
3549
+ parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
3550
+ noCache: { type: "boolean", description: "Bypass the 30s in-memory cache." }
3551
+ }
2995
3552
  }
2996
3553
  },
2997
3554
  {
2998
3555
  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.',
3556
+ description: 'Get logs from one or more Docker containers, optionally across multiple servers. `containerName` accepts a string OR array \u2014 multi-container output is line-prefixed with `[name]`. Supports time-window (`since`), server-side `grep`, line-count (`tail`), and real-time follow (`followSeconds`). Always merges stderr into stdout. Outputs >8 KB get a continuation hint.\n\nFan-out: pass `serverIds: [...]` to tail the same container(s) across multiple servers in one call (e.g. all kong instances). Per-server output is grouped under headers.\n\nFuzzy hint: if a container name typo is detected, the error includes "Did you mean: ..." suggestions from the docker-list cache.',
3000
3557
  inputSchema: {
3001
3558
  type: "object",
3002
3559
  properties: {
3003
- serverId: { type: "string", description: "UUID of the SSH server" },
3560
+ serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
3561
+ serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to tail in parallel. Mutually exclusive with serverId." },
3004
3562
  containerName: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Single container name/ID or an array for multi-container tail. Multi mode prefixes each line with [name]." },
3005
- tail: { type: "number", description: "Number of recent log lines per container (default 100). Alias: `lines`." },
3563
+ tail: { type: "number", description: "Number of recent log lines per container (default 100, default 0 in follow mode)." },
3006
3564
  lines: { type: "number", description: "Deprecated alias for `tail`. Prefer `tail`." },
3007
3565
  since: { type: "string", description: 'Time window, e.g. "10m", "2h", "24h", or an absolute "2026-05-09T10:00:00".' },
3008
3566
  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." }
3567
+ followSeconds: { type: "number", description: "When >0, stream new log lines for N seconds via `docker logs -f`, then exit (max 300). Multi-container follow runs all streams in parallel." },
3568
+ parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." }
3010
3569
  },
3011
- required: ["serverId", "containerName"]
3570
+ required: ["containerName"]
3012
3571
  }
3013
3572
  },
3014
3573
  {
@@ -3046,6 +3605,24 @@ var TOOLS = [
3046
3605
  required: ["serverId", "projectPath", "action"]
3047
3606
  }
3048
3607
  },
3608
+ {
3609
+ name: "wait-for",
3610
+ description: 'Poll until a condition holds (or timeout). Replaces 30+ ad-hoc loops the LLM would otherwise build itself. Four condition types:\n- `url`: `target: "https://..."`, `until: 200` (HTTP status, default 200). 10s per probe.\n- `container-health`: `serverId`, `target: "container-name"`, `until: "healthy" | "running" | "exited"` (default "healthy"). Uses `docker inspect`.\n- `file-exists`: `serverId`, `target: "/path/to/file"` \u2014 SSH stat check.\n- `sftp-exists`: same as file-exists but with `bucket: "name"` instead of serverId for R2.\n\nReturns a per-attempt trail so you can see how long it took to converge.',
3611
+ inputSchema: {
3612
+ type: "object",
3613
+ properties: {
3614
+ type: { type: "string", enum: ["url", "container-health", "file-exists", "sftp-exists"], description: "Condition type to wait on." },
3615
+ target: { type: "string", description: "Target identifier: URL, container name, file path, or R2 key (depends on type)." },
3616
+ until: { description: 'Expected end-state (depends on type): for url an HTTP status (default 200), for container-health a state string (default "healthy"). Omit for file-exists/sftp-exists.' },
3617
+ serverId: { type: "string", description: "SSH server UUID (required for container-health and file-exists)." },
3618
+ bucket: { type: "string", description: "R2 bucket name (used with sftp-exists when checking an R2 object)." },
3619
+ path: { type: "string", description: "Alias for `target` for file-exists/sftp-exists." },
3620
+ intervalSeconds: { type: "number", description: "Poll interval in seconds (default 3, max 60)." },
3621
+ timeout: { type: "number", description: "Total deadline in seconds (default 120, max 1800)." }
3622
+ },
3623
+ required: ["type", "target"]
3624
+ }
3625
+ },
3049
3626
  {
3050
3627
  name: "db-discover",
3051
3628
  description: "Scan /var/www on a server for web applications (WordPress, PrestaShop, Laravel, .env) and list their database credentials. Use this first to find available sites before running other db-* tools.",
@@ -3084,15 +3661,20 @@ var TOOLS = [
3084
3661
  },
3085
3662
  {
3086
3663
  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.",
3664
+ description: 'Execute a SQL query against any database. Two routing modes:\n- MySQL via `/var/www` autodiscover (default): pass `sitePath` (and `engine: "mysql"` implicitly). Credentials are read from wp-config.php, parameters.php, .env. Same behaviour as before.\n- Direct via Docker container: pass `containerName` (the container running the DB server). Engine defaults to `postgres`; pass `engine: "mysql"` or `"mssql"` to override. The query is piped via stdin so quotes / `$` / `;` are never re-interpreted.\n\nPostgres example: `{ serverId, containerName: "supabase-db", engine: "postgres", dbName: "main", dbUser: "postgres", query: "SELECT 1" }`. Defaults: dbUser=postgres, dbName=postgres.\nMSSQL example: `{ serverId, containerName: "mssql-1", engine: "mssql", dbUser: "sa", dbPass: "...", query: "SELECT 1" }`.\n\nDestructive operations (DROP, TRUNCATE, etc.) are blocked.',
3088
3665
  inputSchema: {
3089
3666
  type: "object",
3090
3667
  properties: {
3091
3668
  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" }
3669
+ query: { type: "string", description: "SQL query to execute" },
3670
+ sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
3671
+ containerName: { type: "string", description: 'Docker container running the DB server (e.g. "supabase-db", "trigger-postgres"). Activates direct-query mode.' },
3672
+ engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
3673
+ dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
3674
+ dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
3675
+ dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." }
3094
3676
  },
3095
- required: ["serverId", "sitePath", "query"]
3677
+ required: ["serverId", "query"]
3096
3678
  }
3097
3679
  },
3098
3680
  {
@@ -3238,9 +3820,21 @@ async function handleCallTool(request) {
3238
3820
  }
3239
3821
  const startTime = Date.now();
3240
3822
  const serverId = a.serverId || a.server_id;
3241
- const result = await executeToolCall(name, a);
3823
+ const serverIds = a.serverIds;
3824
+ const cached = getCachedTool(name, a);
3825
+ let cameFromCache = false;
3826
+ let result;
3827
+ if (cached) {
3828
+ result = cached;
3829
+ cameFromCache = true;
3830
+ } else {
3831
+ result = await executeToolCall(name, a);
3832
+ }
3242
3833
  const durationMs = Date.now() - startTime;
3243
3834
  const isError = result.content?.[0]?.text?.startsWith("Error:");
3835
+ if (!cameFromCache && !isError) {
3836
+ setCachedTool(name, a, result);
3837
+ }
3244
3838
  void writeAuditLog({
3245
3839
  toolName: name,
3246
3840
  arguments: a,
@@ -3249,7 +3843,14 @@ async function handleCallTool(request) {
3249
3843
  errorMessage: isError ? result.content?.[0]?.text : void 0,
3250
3844
  durationMs
3251
3845
  });
3252
- return result;
3846
+ const serverIdLabel = await resolveServerLabel(serverId, serverIds);
3847
+ return postprocessResult(result, {
3848
+ startedAtMs: startTime,
3849
+ serverIdLabel,
3850
+ toolName: name,
3851
+ args: a,
3852
+ cached: cameFromCache
3853
+ });
3253
3854
  }
3254
3855
  async function executeToolCall(name, a, _serverId) {
3255
3856
  const ctx = authContext;
@@ -3265,6 +3866,7 @@ async function executeToolCall(name, a, _serverId) {
3265
3866
  if (error) throw new Error(error.message);
3266
3867
  const includeStats = a.includeStats === true;
3267
3868
  const servers = data || [];
3869
+ rememberServers(servers.map((s) => ({ id: s.id, name: s.name })));
3268
3870
  let statsByServer = /* @__PURE__ */ new Map();
3269
3871
  if (includeStats && servers.length > 0) {
3270
3872
  const parallelism = Math.max(1, Math.min(20, Number(a.statsParallelism) || 8));
@@ -3315,17 +3917,34 @@ async function executeToolCall(name, a, _serverId) {
3315
3917
  if (!st.reachable) return `${base} UNREACHABLE (${st.error || "unknown"})`;
3316
3918
  return `${base} containers:${st.containers} disk_free:${st.diskFreeHuman} uptime:${st.uptimeHuman}`;
3317
3919
  });
3318
- return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No servers found" }] };
3920
+ const contextHints = a.context !== false ? buildServerContextHints(servers) : [];
3921
+ const out = lines.length ? lines.join("\n") : "No servers found";
3922
+ return { content: [{ type: "text", text: contextHints.length ? `${out}
3923
+
3924
+ --- context ---
3925
+ ${contextHints.join("\n")}` : out }] };
3319
3926
  }
3320
3927
  // ----- SSH -----
3321
3928
  case "ssh-execute": {
3322
- const command = String(a.command);
3323
- assertSafeCommand(command);
3929
+ const pipeline = Array.isArray(a.pipeline) ? a.pipeline.map(String).filter(Boolean) : [];
3930
+ const command = pipeline.length > 0 ? "" : String(a.command || "");
3931
+ if (!command && pipeline.length === 0) {
3932
+ return { content: [{ type: "text", text: "Error: pass either `command` or `pipeline[]`" }] };
3933
+ }
3934
+ if (command) assertSafeCommand(command);
3935
+ for (const c of pipeline) assertSafeCommand(c);
3324
3936
  const args2 = Array.isArray(a.args) ? a.args.map(String) : void 0;
3325
3937
  const stdin = typeof a.stdin === "string" ? a.stdin : void 0;
3326
3938
  const shellOverride = typeof a.shell === "string" ? a.shell : "auto";
3327
3939
  const cwd = typeof a.cwd === "string" && a.cwd ? a.cwd : void 0;
3328
3940
  const timeoutMs = a.timeout ? Number(a.timeout) : void 0;
3941
+ const rawIdle = a.idleTimeoutSeconds !== void 0 ? Number(a.idleTimeoutSeconds) : 120;
3942
+ const idleTimeoutMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle * 1e3 : void 0;
3943
+ const repeat = Math.max(1, Math.min(60, Number(a.repeat) || 1));
3944
+ const intervalSeconds = Math.max(0, Math.min(600, Number(a.intervalSeconds) || 0));
3945
+ const repeatOutput = a.output === "diff" || a.output === "last" ? a.output : "all";
3946
+ const format = a.format === "ndjson" ? "ndjson" : "text";
3947
+ const pipelineStopOnError = a.pipelineStopOnError === true;
3329
3948
  const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
3330
3949
  if (targetIds.length === 0) {
3331
3950
  return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
@@ -3336,44 +3955,88 @@ async function executeToolCall(name, a, _serverId) {
3336
3955
  if (timeoutMs) conn.timeout = timeoutMs;
3337
3956
  const shell = shellOverride === "auto" ? os === "windows" ? "powershell" : "bash" : shellOverride;
3338
3957
  let body;
3339
- if (args2 && args2.length > 0) {
3958
+ let pipelineMarker = null;
3959
+ if (pipeline.length > 0) {
3960
+ pipelineMarker = `__MCP_BREAK_${randomBytes(8).toString("hex")}__`;
3961
+ body = buildPipelineScript(pipeline, shell, pipelineMarker, pipelineStopOnError);
3962
+ } else if (args2 && args2.length > 0) {
3340
3963
  body = shell === "powershell" ? buildPowerShellEncodedCommand(command, args2) : buildPosixCommand(command, args2);
3341
3964
  } else if (shell === "powershell" && !/^powershell\b/i.test(command.trim())) {
3342
3965
  body = buildPowerShellEncodedCommand(command, []);
3343
3966
  } else {
3344
3967
  body = command;
3345
3968
  }
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}`);
3969
+ const applyCwd = (rawBody) => {
3970
+ if (!cwd) return rawBody;
3971
+ if (shell === "bash") return `cd ${posixQuote(cwd)} && ${rawBody}`;
3972
+ const ecMatch = rawBody.match(/-EncodedCommand\s+(\S+)$/);
3973
+ if (!ecMatch) return rawBody;
3974
+ const decoded = Buffer.from(ecMatch[1], "base64").toString("utf16le");
3975
+ const wrapped = `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${decoded}`;
3976
+ const reencoded = Buffer.from(wrapped, "utf16le").toString("base64");
3977
+ return rawBody.replace(/-EncodedCommand\s+\S+$/, `-EncodedCommand ${reencoded}`);
3978
+ };
3979
+ const finalCmd = applyCwd(body);
3980
+ if (repeat > 1) {
3981
+ const iterations = [];
3982
+ for (let i = 0; i < repeat; i++) {
3983
+ const startedAt = Date.now();
3984
+ const r = await sshExec(conn, finalCmd, proxy, {
3985
+ ...stdin !== void 0 ? { stdin } : {},
3986
+ idleTimeoutMs
3987
+ });
3988
+ iterations.push({ index: i, startedAt, durationMs: Date.now() - startedAt, result: r });
3989
+ if (i < repeat - 1 && intervalSeconds > 0) {
3990
+ await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
3357
3991
  }
3358
3992
  }
3993
+ const last = iterations[iterations.length - 1].result;
3994
+ let serverName2 = serverId;
3995
+ try {
3996
+ const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
3997
+ if (data?.name) serverName2 = data.name;
3998
+ } catch {
3999
+ }
4000
+ return { serverId, serverName: serverName2, os, shell, result: last, iterations };
4001
+ }
4002
+ const result = await sshExec(conn, finalCmd, proxy, {
4003
+ ...stdin !== void 0 ? { stdin } : {},
4004
+ idleTimeoutMs
4005
+ });
4006
+ let pipelineSegments;
4007
+ if (pipelineMarker && pipeline.length > 0) {
4008
+ pipelineSegments = parsePipelineOutput(result.stdout, pipeline, pipelineMarker);
3359
4009
  }
3360
- const result = await sshExec(conn, finalCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
3361
4010
  let serverName = serverId;
3362
4011
  try {
3363
- const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).single();
4012
+ const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
3364
4013
  if (data?.name) serverName = data.name;
3365
4014
  } catch {
3366
4015
  }
3367
- return { serverId, serverName, os, shell, result };
4016
+ return { serverId, serverName, os, shell, result, pipelineSegments };
4017
+ };
4018
+ const renderOne = (r) => {
4019
+ if (r.iterations && r.iterations.length > 0) {
4020
+ return renderRepeatResult(r.iterations, repeatOutput, format);
4021
+ }
4022
+ if (r.pipelineSegments) {
4023
+ return renderPipelineSegments(r.pipelineSegments, r.result, format);
4024
+ }
4025
+ const output = [`Exit code: ${r.result.exitCode} (os: ${r.os}, shell: ${r.shell})`];
4026
+ let stdoutText = r.result.stdout;
4027
+ if (format === "ndjson" && stdoutText) {
4028
+ const parsed = tryParseNdjsonFromCommand(command, stdoutText);
4029
+ if (parsed) stdoutText = parsed;
4030
+ }
4031
+ if (stdoutText) output.push(`--- stdout ---
4032
+ ${stdoutText}`);
4033
+ if (r.result.stderr) output.push(`--- stderr ---
4034
+ ${r.result.stderr}`);
4035
+ return output.join("\n");
3368
4036
  };
3369
4037
  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") }] };
4038
+ const r = await runOne(targetIds[0]);
4039
+ return { content: [{ type: "text", text: renderOne(r) }] };
3377
4040
  }
3378
4041
  const allResults = [];
3379
4042
  for (let i = 0; i < targetIds.length; i += parallelism) {
@@ -3397,10 +4060,8 @@ ${result.stderr}`);
3397
4060
  if (r.result.exitCode === 0) okCount++;
3398
4061
  else failCount++;
3399
4062
  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"));
4063
+ sections.push(`${header}
4064
+ ${renderOne(r)}`);
3404
4065
  }
3405
4066
  sections.push(`--- fan-out summary: ${okCount} ok / ${failCount} failed across ${allResults.length} servers (parallelism ${parallelism}) ---`);
3406
4067
  return { content: [{ type: "text", text: sections.join("\n\n") }] };
@@ -3502,6 +4163,46 @@ ${result.stderr}`);
3502
4163
  }
3503
4164
  case "sftp-write": {
3504
4165
  const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
4166
+ if (a.sourceRemote && typeof a.sourceRemote === "object") {
4167
+ const srcRaw = a.sourceRemote;
4168
+ const srcBucket = typeof srcRaw.bucket === "string" && srcRaw.bucket ? String(srcRaw.bucket) : "";
4169
+ const srcServerId = typeof srcRaw.serverId === "string" && srcRaw.serverId ? String(srcRaw.serverId) : "";
4170
+ const srcPath = String(srcRaw.path || "");
4171
+ if (!srcPath) return { content: [{ type: "text", text: "Error: sourceRemote.path is required" }] };
4172
+ if (!srcBucket && !srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote needs `serverId` or `bucket`" }] };
4173
+ if (srcBucket && srcServerId) return { content: [{ type: "text", text: "Error: sourceRemote can have either `serverId` or `bucket`, not both" }] };
4174
+ const dstPath = String(a.path || "");
4175
+ if (!dstPath) return { content: [{ type: "text", text: "Error: target `path` is required" }] };
4176
+ let srcEp;
4177
+ if (srcBucket) {
4178
+ const k = r2Key(srcPath);
4179
+ if (!k) return { content: [{ type: "text", text: "Error: sourceRemote.path resolves to empty key" }] };
4180
+ srcEp = { kind: "r2", bucket: srcBucket, key: k };
4181
+ } else {
4182
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(srcServerId);
4183
+ srcEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: srcPath };
4184
+ }
4185
+ let dstEp;
4186
+ if (bucket) {
4187
+ const k = r2Key(dstPath);
4188
+ if (!k) return { content: [{ type: "text", text: "Error: target path resolves to empty key" }] };
4189
+ dstEp = { kind: "r2", bucket, key: k };
4190
+ } else if (a.serverId) {
4191
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4192
+ dstEp = { kind: "ssh", opts: conn2, proxy: proxy2, path: dstPath };
4193
+ } else {
4194
+ return { content: [{ type: "text", text: "Error: target needs `serverId` or `bucket`" }] };
4195
+ }
4196
+ try {
4197
+ const { bytes, elapsedMs, mode } = await sftpCopy(srcEp, dstEp);
4198
+ const srcLabel = srcEp.kind === "r2" ? `r2://${srcEp.bucket}/${srcEp.key}` : `ssh:${srcPath}`;
4199
+ const dstLabel = dstEp.kind === "r2" ? `r2://${dstEp.bucket}/${dstEp.key}` : `ssh:${dstPath}`;
4200
+ const mbps = bytes > 0 && elapsedMs > 0 ? bytes / (1024 * 1024) / (elapsedMs / 1e3) : 0;
4201
+ return { content: [{ type: "text", text: `Copied ${formatBytes(bytes)} from ${srcLabel} \u2192 ${dstLabel} via ${mode} in ${(elapsedMs / 1e3).toFixed(2)}s${mbps > 0 ? ` (${mbps.toFixed(1)} MB/s)` : ""}` }] };
4202
+ } catch (e) {
4203
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
4204
+ }
4205
+ }
3505
4206
  let fileMode;
3506
4207
  if (typeof a.mode === "number" && Number.isFinite(a.mode)) fileMode = a.mode & 4095;
3507
4208
  else if (typeof a.mode === "string" && a.mode) {
@@ -3564,14 +4265,51 @@ ${result.stderr}`);
3564
4265
  case "sftp-delete": {
3565
4266
  const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
3566
4267
  const recursive = a.recursive === true;
4268
+ const dryRun = a.dryRun === true;
4269
+ const confirmAbove = a.confirmAbove !== void 0 ? Math.max(0, Number(a.confirmAbove)) : void 0;
4270
+ const confirmCount = a.confirmCount !== void 0 ? Number(a.confirmCount) : void 0;
4271
+ const guard = (count, sizeBytes, where) => {
4272
+ if (confirmAbove !== void 0 && count > confirmAbove) {
4273
+ if (confirmCount === count) return null;
4274
+ return {
4275
+ content: [{
4276
+ type: "text",
4277
+ text: `Refusing to delete ${count} item(s) (${formatBytes(sizeBytes)}) under ${where}: exceeds confirmAbove=${confirmAbove}. To proceed, re-issue the call with confirmCount: ${count}.`
4278
+ }]
4279
+ };
4280
+ }
4281
+ return null;
4282
+ };
3567
4283
  if (bucket) {
3568
4284
  const key = r2Key(String(a.path));
3569
4285
  if (!key) return { content: [{ type: "text", text: "Error: path is required" }] };
3570
4286
  try {
3571
4287
  if (recursive) {
4288
+ if (dryRun || confirmAbove !== void 0) {
4289
+ const listed = await r2List(bucket, key, { recursive: true, maxResults: 5e4 });
4290
+ const objects = listed.filter((e) => !e.isPrefix);
4291
+ const totalSize = objects.reduce((s, o) => s + (o.size || 0), 0);
4292
+ if (dryRun) {
4293
+ const sample = objects.slice(0, 5).map((o) => ` - r2://${bucket}/${o.key} (${formatBytes(o.size || 0)})`);
4294
+ return { content: [{ type: "text", text: `[dry-run] Would delete ${objects.length} object(s) totalling ${formatBytes(totalSize)} under r2://${bucket}/${key}${objects.length > 5 ? `
4295
+ ${sample.join("\n")}
4296
+ ... and ${objects.length - 5} more` : objects.length ? `
4297
+ ${sample.join("\n")}` : ""}` }] };
4298
+ }
4299
+ const block = guard(objects.length, totalSize, `r2://${bucket}/${key}`);
4300
+ if (block) return block;
4301
+ }
3572
4302
  const deleted = await r2DeletePrefix(bucket, key);
3573
4303
  return { content: [{ type: "text", text: `Deleted ${deleted} object(s) under r2://${bucket}/${key}` }] };
3574
4304
  }
4305
+ if (dryRun) {
4306
+ try {
4307
+ const head = await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
4308
+ return { content: [{ type: "text", text: `[dry-run] Would delete r2://${bucket}/${key} (${formatBytes(head.ContentLength || 0)})` }] };
4309
+ } catch {
4310
+ return { content: [{ type: "text", text: `[dry-run] r2://${bucket}/${key} not found (delete would 404)` }] };
4311
+ }
4312
+ }
3575
4313
  await r2DeleteObject(bucket, key);
3576
4314
  return { content: [{ type: "text", text: `Deleted r2://${bucket}/${key}` }] };
3577
4315
  } catch (e) {
@@ -3579,6 +4317,25 @@ ${result.stderr}`);
3579
4317
  }
3580
4318
  }
3581
4319
  const { conn, proxy } = await getServerConnection(String(a.serverId));
4320
+ if (recursive && (dryRun || confirmAbove !== void 0)) {
4321
+ const listed = await sftpReaddir(conn, String(a.path), proxy, {
4322
+ recursive: true,
4323
+ maxResults: 5e4
4324
+ });
4325
+ const files = listed.entries.filter((e) => e.kind === "-");
4326
+ const dirs = listed.entries.filter((e) => e.kind === "d");
4327
+ const totalSize = files.reduce((s, f) => s + (f.size || 0), 0);
4328
+ if (dryRun) {
4329
+ const sample = files.slice(0, 5).map((f) => ` - ${f.path} (${formatBytes(f.size || 0)})`);
4330
+ return { content: [{ type: "text", text: `[dry-run] Would delete ${files.length} file(s) + ${dirs.length} directory(ies) totalling ${formatBytes(totalSize)} under ${a.path}${files.length ? `
4331
+ ${sample.join("\n")}${files.length > 5 ? `
4332
+ ... and ${files.length - 5} more` : ""}` : ""}` }] };
4333
+ }
4334
+ const block = guard(files.length + dirs.length, totalSize, String(a.path));
4335
+ if (block) return block;
4336
+ } else if (dryRun) {
4337
+ return { content: [{ type: "text", text: `[dry-run] Would delete ${a.path} (single file/dir; pass recursive: true to preview tree contents)` }] };
4338
+ }
3582
4339
  const result = await sftpDelete(conn, String(a.path), proxy, { recursive });
3583
4340
  return { content: [{ type: "text", text: result }] };
3584
4341
  }
@@ -3588,75 +4345,136 @@ ${result.stderr}`);
3588
4345
  const composeOnly = a.composeOnly === true;
3589
4346
  const groupByProject = a.groupByProject === true;
3590
4347
  const nameFiltersRaw = Array.isArray(a.nameFilter) ? a.nameFilter.map((v) => String(v).trim()).filter(Boolean) : typeof a.nameFilter === "string" && a.nameFilter.trim() ? [a.nameFilter.trim()] : [];
4348
+ const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
4349
+ if (targetIds.length === 0) {
4350
+ return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
4351
+ }
4352
+ const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
3591
4353
  const filterArg = composeOnly ? ` --filter "label=com.docker.compose.project"` : "";
3592
4354
  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;
4355
+ const probeOne = async (serverId) => {
4356
+ let serverName = serverId.slice(0, 8);
3602
4357
  try {
3603
- containers.push(JSON.parse(trimmed));
3604
- } catch {
4358
+ const { conn, proxy } = await getServerConnection(serverId);
4359
+ const r = await sshExec(conn, cmd, proxy);
4360
+ const cached = KNOWN_SERVER_NAMES.get(serverId);
4361
+ if (cached) serverName = cached.name;
4362
+ if (r.exitCode !== 0) {
4363
+ return { serverId, serverName, rows: [], error: (r.stderr || r.stdout || "exit " + r.exitCode).trim().slice(0, 200) };
4364
+ }
4365
+ const rows = [];
4366
+ for (const line of r.stdout.split("\n")) {
4367
+ const t = line.trim();
4368
+ if (!t) continue;
4369
+ try {
4370
+ rows.push(JSON.parse(t));
4371
+ } catch {
4372
+ }
4373
+ }
4374
+ for (const c of rows) {
4375
+ const m = (c.Labels || "").match(/com\.docker\.compose\.project=([^,]+)/);
4376
+ c.Project = m ? m[1] : "";
4377
+ const status = c.Status || "";
4378
+ if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
4379
+ else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
4380
+ else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
4381
+ else if (/^Up\b/i.test(status)) c.Health = "no-check";
4382
+ else c.Health = "";
4383
+ }
4384
+ rememberContainers(serverId, rows.map((r2) => r2.Names || "").filter(Boolean));
4385
+ return { serverId, serverName, rows };
4386
+ } catch (err) {
4387
+ return { serverId, serverName, rows: [], error: err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200) };
3605
4388
  }
4389
+ };
4390
+ const perServer = [];
4391
+ for (let i = 0; i < targetIds.length; i += parallelism) {
4392
+ const batch = targetIds.slice(i, i + parallelism);
4393
+ const chunk = await Promise.all(batch.map(probeOne));
4394
+ perServer.push(...chunk);
3606
4395
  }
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) {
4396
+ const applyFilter = (rows) => {
4397
+ if (nameFiltersRaw.length === 0) return rows;
3619
4398
  const matchers = nameFiltersRaw.map((raw) => {
3620
4399
  if (raw.includes("*") || raw.includes("?")) return { kind: "glob", re: globToRegExp(raw) };
3621
4400
  return { kind: "sub", lower: raw.toLowerCase() };
3622
4401
  });
3623
- filtered = containers.filter((c) => {
4402
+ return rows.filter((c) => {
3624
4403
  const name2 = c.Names || "";
3625
4404
  const lower = name2.toLowerCase();
3626
4405
  return matchers.some((m) => m.kind === "glob" ? m.re.test(name2) : lower.includes(m.lower));
3627
4406
  });
3628
- }
3629
- filtered.sort((x, y) => {
4407
+ };
4408
+ const sortRows = (rows) => rows.sort((x, y) => {
3630
4409
  const px = x.Project || "~";
3631
4410
  const py = y.Project || "~";
3632
4411
  const p = px.localeCompare(py);
3633
4412
  return p !== 0 ? p : (x.Names || "").localeCompare(y.Names || "");
3634
4413
  });
4414
+ const fmtRow = (r) => `${(r.Names || "").padEnd(36)} ${(r.Image || "").padEnd(40)} ${(r.Status || "").padEnd(24)} ${(r.Health || "").padEnd(10)} ${(r.Ports || "").padEnd(40)}`;
4415
+ if (targetIds.length === 1) {
4416
+ const only = perServer[0];
4417
+ if (only.error) return { content: [{ type: "text", text: `Error: ${only.error}` }] };
4418
+ const filtered = sortRows(applyFilter(only.rows));
4419
+ if (format === "json") {
4420
+ const text2 = filtered.map((c) => JSON.stringify(c)).join("\n") || "(no containers)";
4421
+ return { content: [{ type: "text", text: text2 }] };
4422
+ }
4423
+ if (groupByProject) {
4424
+ const groups = /* @__PURE__ */ new Map();
4425
+ for (const c of filtered) {
4426
+ const key = c.Project || "(no compose project)";
4427
+ const list = groups.get(key);
4428
+ if (list) list.push(c);
4429
+ else groups.set(key, [c]);
4430
+ }
4431
+ const out = [];
4432
+ for (const [project, rows] of groups) {
4433
+ out.push(`=== ${project} (${rows.length}) ===`);
4434
+ for (const r of rows) out.push(" " + fmtRow(r));
4435
+ out.push("");
4436
+ }
4437
+ return { content: [{ type: "text", text: out.join("\n").trimEnd() || "(no containers)" }] };
4438
+ }
4439
+ const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
4440
+ const body = filtered.map((r) => `${fmtRow(r)} ${r.Project || ""}`);
4441
+ const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
4442
+ return { content: [{ type: "text", text }] };
4443
+ }
3635
4444
  if (format === "json") {
3636
- const text2 = filtered.map((c) => JSON.stringify(c)).join("\n") || "(no containers)";
3637
- return { content: [{ type: "text", text: text2 }] };
4445
+ const lines = [];
4446
+ for (const ps of perServer) {
4447
+ if (ps.error) {
4448
+ lines.push(JSON.stringify({ Server: ps.serverName, Error: ps.error }));
4449
+ continue;
4450
+ }
4451
+ const filtered = sortRows(applyFilter(ps.rows));
4452
+ for (const c of filtered) lines.push(JSON.stringify({ Server: ps.serverName, ...c }));
4453
+ }
4454
+ return { content: [{ type: "text", text: lines.join("\n") || "(no containers)" }] };
3638
4455
  }
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]);
4456
+ const sections = [];
4457
+ let totalRows = 0;
4458
+ let serversWithRows = 0;
4459
+ for (const ps of perServer) {
4460
+ if (ps.error) {
4461
+ sections.push(`=== ${ps.serverName} === !! ${ps.error}`);
4462
+ continue;
3647
4463
  }
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("");
4464
+ const filtered = sortRows(applyFilter(ps.rows));
4465
+ if (filtered.length === 0) {
4466
+ sections.push(`=== ${ps.serverName} === (no containers match)`);
4467
+ continue;
3653
4468
  }
3654
- return { content: [{ type: "text", text: out.join("\n").trimEnd() || "(no containers)" }] };
4469
+ serversWithRows++;
4470
+ totalRows += filtered.length;
4471
+ sections.push(`=== ${ps.serverName} (${filtered.length} container(s)) ===`);
4472
+ const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
4473
+ sections.push(header);
4474
+ for (const r of filtered) sections.push(`${fmtRow(r)} ${r.Project || ""}`);
3655
4475
  }
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 }] };
4476
+ sections.push(`--- fan-out summary: ${totalRows} container(s) across ${serversWithRows}/${perServer.length} server(s) ---`);
4477
+ return { content: [{ type: "text", text: sections.join("\n") }] };
3660
4478
  }
3661
4479
  case "docker-logs": {
3662
4480
  const rawNames = Array.isArray(a.containerName) ? a.containerName.map(String) : [String(a.containerName)];
@@ -3674,37 +4492,71 @@ ${result.stderr}`);
3674
4492
  }
3675
4493
  const sinceArg = sinceRaw ? ` --since ${posixQuote(sinceRaw)}` : "";
3676
4494
  const grepSuffix = grepRaw ? ` | grep -i -E ${posixQuote(grepRaw)} --line-buffered` : "";
3677
- const { conn, proxy } = await getServerConnection(String(a.serverId));
4495
+ const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
4496
+ if (targetIds.length === 0) {
4497
+ return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
4498
+ }
4499
+ const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
3678
4500
  const wallSeconds = followSeconds > 0 ? followSeconds : 30;
3679
- conn.timeout = (wallSeconds + 10) * 1e3;
3680
4501
  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)"}` }] };
4502
+ const buildCmd = () => {
4503
+ if (containers.length === 1) {
4504
+ const c = containers[0];
4505
+ const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
4506
+ return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(inner)}`;
3689
4507
  }
3690
- const note2 = result2.exitCode === 124 ? `
4508
+ const subShells = containers.map((c) => {
4509
+ const inner = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
4510
+ return `(${inner} | sed -u -e ${posixQuote(`s/^/[${c}] /`)})`;
4511
+ });
4512
+ const innerCmd = subShells.join(" & ") + " & wait";
4513
+ return `timeout --signal=INT ${wallSeconds} sh -c ${posixQuote(innerCmd)}`;
4514
+ };
4515
+ const cmd = buildCmd();
4516
+ const probeOne = async (serverId) => {
4517
+ const { conn, proxy } = await getServerConnection(serverId);
4518
+ conn.timeout = (wallSeconds + 10) * 1e3;
4519
+ const result = await sshExec(conn, cmd, proxy);
4520
+ const cached = KNOWN_SERVER_NAMES.get(serverId);
4521
+ return { serverId, serverName: cached?.name || serverId.slice(0, 8), result };
4522
+ };
4523
+ const acceptableCode = (code) => code === 0 || code === 124 || code === 130 || code === 143 || !!grepRaw && code === 1;
4524
+ if (targetIds.length === 1) {
4525
+ const r = await probeOne(targetIds[0]);
4526
+ const result = r.result;
4527
+ if (!acceptableCode(result.exitCode) && !result.stdout) {
4528
+ const known = getKnownContainers(targetIds[0]);
4529
+ if (known && containers.length === 1) {
4530
+ const hits = suggestSimilar(containers[0], known);
4531
+ const hint = hits.length ? ` Did you mean: ${hits.join(", ")}?` : "";
4532
+ return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}${hint}` }] };
4533
+ }
4534
+ return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || "(no output)"}` }] };
4535
+ }
4536
+ const note = result.exitCode === 124 ? `
3691
4537
  (note: command exceeded ${wallSeconds}s wall budget; partial output)` : "";
3692
- return { content: [{ type: "text", text: (result2.stdout || "(no log lines matched)") + note2 }] };
4538
+ return { content: [{ type: "text", text: (result.stdout || "(no log lines matched)") + note }] };
3693
4539
  }
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)"}` }] };
4540
+ const perServer = [];
4541
+ for (let i = 0; i < targetIds.length; i += parallelism) {
4542
+ const batch = targetIds.slice(i, i + parallelism);
4543
+ const chunk = await Promise.all(batch.map(probeOne));
4544
+ perServer.push(...chunk);
3704
4545
  }
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 }] };
4546
+ const sections = [];
4547
+ let okCount = 0;
4548
+ for (const ps of perServer) {
4549
+ if (acceptableCode(ps.result.exitCode) || ps.result.stdout) {
4550
+ okCount++;
4551
+ const note = ps.result.exitCode === 124 ? ` (timed out after ${wallSeconds}s; partial)` : "";
4552
+ sections.push(`=== ${ps.serverName}${note} ===`);
4553
+ sections.push(ps.result.stdout || "(no log lines matched)");
4554
+ } else {
4555
+ sections.push(`=== ${ps.serverName} === !! exit ${ps.result.exitCode}: ${ps.result.stderr.trim().slice(0, 200) || "(no output)"}`);
4556
+ }
4557
+ }
4558
+ sections.push(`--- fan-out summary: ${okCount}/${perServer.length} server(s) returned logs ---`);
4559
+ return { content: [{ type: "text", text: sections.join("\n") }] };
3708
4560
  }
3709
4561
  case "docker-exec": {
3710
4562
  const container = String(a.container).replace(/[^a-zA-Z0-9._-]/g, "");
@@ -3729,8 +4581,20 @@ ${result.stderr}`);
3729
4581
  const output = [`Exit code: ${result.exitCode}`];
3730
4582
  if (result.stdout) output.push(`--- stdout ---
3731
4583
  ${result.stdout}`);
3732
- if (result.stderr) output.push(`--- stderr ---
3733
- ${result.stderr}`);
4584
+ if (result.stderr) {
4585
+ let stderrOut = result.stderr;
4586
+ const noSuch = /No such container:\s*(\S+)/i.exec(result.stderr);
4587
+ if (noSuch) {
4588
+ const known = getKnownContainers(String(a.serverId));
4589
+ if (known && known.length) {
4590
+ const hits = suggestSimilar(noSuch[1], known);
4591
+ if (hits.length) stderrOut += `
4592
+ (hint) Did you mean: ${hits.join(", ")}?`;
4593
+ }
4594
+ }
4595
+ output.push(`--- stderr ---
4596
+ ${stderrOut}`);
4597
+ }
3734
4598
  return { content: [{ type: "text", text: output.join("\n") }] };
3735
4599
  }
3736
4600
  case "docker-compose": {
@@ -3782,6 +4646,94 @@ ${result.stderr}`);
3782
4646
  ${result.stderr}`);
3783
4647
  return { content: [{ type: "text", text: output.join("\n") }] };
3784
4648
  }
4649
+ // ----- wait-for: poll until a condition holds (one tool, four types) -----
4650
+ case "wait-for": {
4651
+ const type = String(a.type || "");
4652
+ if (!["url", "container-health", "file-exists", "sftp-exists"].includes(type)) {
4653
+ return { content: [{ type: "text", text: "Error: type must be one of: url, container-health, file-exists, sftp-exists" }] };
4654
+ }
4655
+ const timeoutSeconds = Math.max(1, Math.min(1800, Number(a.timeout) || 120));
4656
+ const intervalSeconds = Math.max(1, Math.min(60, Number(a.intervalSeconds) || 3));
4657
+ const deadline = Date.now() + timeoutSeconds * 1e3;
4658
+ const trail = [];
4659
+ const check = async () => {
4660
+ if (type === "url") {
4661
+ const target = String(a.target || "");
4662
+ if (!target) return { ok: false, observed: "no target url" };
4663
+ const expectStatus = a.until !== void 0 ? Number(a.until) : 200;
4664
+ try {
4665
+ const ctrl = new AbortController();
4666
+ const t = setTimeout(() => ctrl.abort(), 1e4);
4667
+ const res = await fetch(target, { signal: ctrl.signal, redirect: "manual" });
4668
+ clearTimeout(t);
4669
+ const ok2 = res.status === expectStatus || expectStatus === 200 && res.status >= 200 && res.status < 300;
4670
+ return { ok: ok2, observed: `HTTP ${res.status}` };
4671
+ } catch (e) {
4672
+ return { ok: false, observed: e instanceof Error ? e.message : String(e) };
4673
+ }
4674
+ }
4675
+ if (type === "container-health") {
4676
+ const containerName = String(a.target || "").replace(/[^a-zA-Z0-9._-]/g, "");
4677
+ const until = String(a.until || "healthy");
4678
+ if (!containerName) return { ok: false, observed: "no container name" };
4679
+ if (!a.serverId) return { ok: false, observed: "serverId is required for container-health" };
4680
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4681
+ const cmd2 = `docker inspect --format '{{json .State}}' ${containerName} 2>&1`;
4682
+ const r2 = await sshExec(conn2, cmd2, proxy2);
4683
+ if (r2.exitCode !== 0) return { ok: false, observed: r2.stderr.trim().slice(0, 80) || "inspect failed" };
4684
+ try {
4685
+ const state = JSON.parse(r2.stdout.trim());
4686
+ const healthStatus = state.Health?.Status;
4687
+ if (until === "healthy") {
4688
+ return { ok: healthStatus === "healthy", observed: `Status=${state.Status} Health=${healthStatus || "no-check"}` };
4689
+ }
4690
+ return { ok: state.Status === until, observed: `Status=${state.Status}` };
4691
+ } catch (e) {
4692
+ return { ok: false, observed: "parse error: " + (e instanceof Error ? e.message : String(e)) };
4693
+ }
4694
+ }
4695
+ const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
4696
+ const path = String(a.target || a.path || "");
4697
+ if (!path) return { ok: false, observed: "no path" };
4698
+ if (bucket) {
4699
+ try {
4700
+ await getR2Client().send(new HeadObjectCommand({ Bucket: bucket, Key: r2Key(path) }));
4701
+ return { ok: true, observed: `r2://${bucket}/${path} exists` };
4702
+ } catch {
4703
+ return { ok: false, observed: `r2://${bucket}/${path} not found` };
4704
+ }
4705
+ }
4706
+ if (!a.serverId) return { ok: false, observed: "serverId or bucket is required for file-exists" };
4707
+ const { conn, proxy } = await getServerConnection(String(a.serverId));
4708
+ const safe = sanitizePath(path);
4709
+ const cmd = `test -e ${posixQuote(safe)} && echo OK || echo MISSING`;
4710
+ const r = await sshExec(conn, cmd, proxy);
4711
+ const ok = r.stdout.trim() === "OK";
4712
+ return { ok, observed: ok ? `${safe} exists` : `${safe} missing` };
4713
+ };
4714
+ let attempts = 0;
4715
+ let lastObserved = "";
4716
+ while (Date.now() < deadline) {
4717
+ attempts++;
4718
+ const r = await check();
4719
+ lastObserved = r.observed;
4720
+ trail.push(`[${attempts}] ${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)} ${r.ok ? "OK" : ".."} ${r.observed}`);
4721
+ if (r.ok) {
4722
+ return { content: [{ type: "text", text: `Condition met after ${attempts} attempt(s) in ${((Date.now() - (deadline - timeoutSeconds * 1e3)) / 1e3).toFixed(1)}s.
4723
+ Last observation: ${r.observed}
4724
+
4725
+ --- trail ---
4726
+ ${trail.join("\n")}` }] };
4727
+ }
4728
+ if (Date.now() + intervalSeconds * 1e3 >= deadline) break;
4729
+ await new Promise((res) => setTimeout(res, intervalSeconds * 1e3));
4730
+ }
4731
+ return { content: [{ type: "text", text: `Timed out after ${timeoutSeconds}s without seeing condition (${attempts} probe(s)).
4732
+ Last observation: ${lastObserved}
4733
+
4734
+ --- trail ---
4735
+ ${trail.join("\n")}` }] };
4736
+ }
3785
4737
  // ----- Database -----
3786
4738
  case "db-discover": {
3787
4739
  const { conn, proxy } = await getServerConnection(String(a.serverId));
@@ -3813,10 +4765,55 @@ ${result.stderr}`);
3813
4765
  }
3814
4766
  case "db-query": {
3815
4767
  const query = String(a.query).trim();
4768
+ if (!query) return { content: [{ type: "text", text: "Error: query is required" }] };
3816
4769
  assertSafeSql(query);
4770
+ const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName.replace(/[^a-zA-Z0-9._-]/g, "") : "";
4771
+ const explicitEngine = a.engine === "mysql" || a.engine === "postgres" || a.engine === "mssql" ? a.engine : null;
4772
+ const engine = explicitEngine || (containerName ? "postgres" : "mysql");
4773
+ if (!containerName && engine === "mysql") {
4774
+ if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
4775
+ const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4776
+ const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
4777
+ return { content: [{ type: "text", text: output2 || "Query executed successfully (no output)" }] };
4778
+ }
4779
+ if (!containerName) {
4780
+ return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
4781
+ }
4782
+ const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName.replace(/[^a-zA-Z0-9_-]/g, "") : "";
4783
+ const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") : "";
3817
4784
  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)" }] };
4785
+ let cmd;
4786
+ let stdinPayload;
4787
+ if (engine === "postgres") {
4788
+ const user = dbUser || "postgres";
4789
+ const db = dbName || "postgres";
4790
+ cmd = `docker exec -i ${containerName} psql -U ${user} -d ${db} -P pager=off`;
4791
+ stdinPayload = query.endsWith(";") ? query : `${query};`;
4792
+ } else if (engine === "mssql") {
4793
+ const user = dbUser || "sa";
4794
+ const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
4795
+ if (!dbPass) return { content: [{ type: "text", text: "Error: mssql requires dbPass (sqlcmd has no equivalent of pg trust auth)" }] };
4796
+ const dbArg = dbName ? `-d ${dbName} ` : "";
4797
+ cmd = `docker exec -i -e MSSQL_SA_PASSWORD=${posixQuote(dbPass)} ${containerName} /opt/mssql-tools18/bin/sqlcmd -S localhost -U ${user} -P "$MSSQL_SA_PASSWORD" -C ${dbArg}-h -1`;
4798
+ stdinPayload = query.endsWith(";") ? `${query}
4799
+ GO
4800
+ ` : `${query};
4801
+ GO
4802
+ `;
4803
+ } else {
4804
+ const user = dbUser || "root";
4805
+ const db = dbName ? ` ${dbName}` : "";
4806
+ const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
4807
+ const passArg = dbPass ? `-p${posixQuote(dbPass)} ` : "";
4808
+ cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${db}`;
4809
+ stdinPayload = query.endsWith(";") ? query : `${query};`;
4810
+ }
4811
+ const result = await sshExec(conn, cmd, proxy, { stdin: stdinPayload });
4812
+ const output = result.stdout.trim() || result.stderr.trim() || "(no output)";
4813
+ if (result.exitCode !== 0 && !result.stdout) {
4814
+ return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
4815
+ }
4816
+ return { content: [{ type: "text", text: output }] };
3820
4817
  }
3821
4818
  // ----- Env Config -----
3822
4819
  case "env-list": {