@mgsoftwarebv/mg-dashboard-mcp 3.7.0 → 3.10.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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'child_process';
3
- import { existsSync, statSync, readFileSync } from 'fs';
4
- import { join, isAbsolute } from 'path';
3
+ import { existsSync, statSync, createReadStream, readFileSync } from 'fs';
4
+ import { isAbsolute, join } from 'path';
5
5
  import { Client as Client$1 } from '@modelcontextprotocol/sdk/client/index.js';
6
6
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
7
7
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -14,6 +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
18
 
18
19
  var __defProp = Object.defineProperty;
19
20
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -1212,8 +1213,6 @@ LinkedIn: ${pageLinkedIn.join(", ")}`;
1212
1213
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
1213
1214
  }
1214
1215
  }
1215
-
1216
- // src/index.ts
1217
1216
  var args = process.argv.slice(2);
1218
1217
  function getArg2(name) {
1219
1218
  return args.find((a) => a.startsWith(`--${name}=`))?.split("=").slice(1).join("=");
@@ -1383,8 +1382,6 @@ var TOOL_MODULE_MAP = {
1383
1382
  "env-get": "ci_cd",
1384
1383
  "env-store": "ci_cd",
1385
1384
  "domain-list": "domains",
1386
- "domain-get": "domains",
1387
- "domain-update-ns": "domains",
1388
1385
  "dns-list": "domains",
1389
1386
  "dns-create": "domains",
1390
1387
  "dns-update": "domains",
@@ -1842,7 +1839,7 @@ function buildPowerShellEncodedCommand(command, args2) {
1842
1839
  const psExpr = `$ProgressPreference='SilentlyContinue'; ${body}`;
1843
1840
  const utf16 = Buffer.from(psExpr, "utf16le");
1844
1841
  const b64 = utf16.toString("base64");
1845
- return `powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${b64}`;
1842
+ return `powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -OutputFormat Text -EncodedCommand ${b64}`;
1846
1843
  }
1847
1844
  var SSH_PROXY_SERVER_ID = "03659d55-e194-400d-b82a-bf6457371ded";
1848
1845
  var _proxyConnCache = null;
@@ -1895,7 +1892,8 @@ async function sshExec(opts, command, proxy, options) {
1895
1892
  }
1896
1893
  }, timeout);
1897
1894
  ssh.on("ready", () => {
1898
- ssh.exec(command, (err, stream) => {
1895
+ const execOpts = options?.pty ? { pty: true } : {};
1896
+ ssh.exec(command, execOpts, (err, stream) => {
1899
1897
  if (err) {
1900
1898
  if (!done) {
1901
1899
  done = true;
@@ -1972,7 +1970,8 @@ function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
1972
1970
  let stdout = "";
1973
1971
  let stderr = "";
1974
1972
  targetClient.on("ready", () => {
1975
- targetClient.exec(command, (execErr, stream) => {
1973
+ const execOpts = options?.pty ? { pty: true } : {};
1974
+ targetClient.exec(command, execOpts, (execErr, stream) => {
1976
1975
  if (execErr) {
1977
1976
  if (!done) {
1978
1977
  done = true;
@@ -2103,6 +2102,174 @@ function connectSshClient(opts, proxy, readyTimeout = 6e4, extraConnect) {
2103
2102
  });
2104
2103
  });
2105
2104
  }
2105
+ var _r2Client = null;
2106
+ function getR2Client() {
2107
+ if (_r2Client) return _r2Client;
2108
+ const endpoint = process.env.R2_ENDPOINT;
2109
+ const accessKeyId = process.env.R2_ACCESS_KEY_ID;
2110
+ const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
2111
+ if (!endpoint || !accessKeyId || !secretAccessKey) {
2112
+ throw new Error(
2113
+ "R2 not configured. Set R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY in the MCP env (mcp.json)."
2114
+ );
2115
+ }
2116
+ _r2Client = new S3Client({ region: "auto", endpoint, credentials: { accessKeyId, secretAccessKey } });
2117
+ return _r2Client;
2118
+ }
2119
+ function r2Key(path) {
2120
+ return path.replace(/^\/+/, "");
2121
+ }
2122
+ async function r2List(bucket, prefix, options) {
2123
+ const client = getR2Client();
2124
+ const entries = [];
2125
+ let continuationToken;
2126
+ const delimiter = options.recursive ? void 0 : "/";
2127
+ do {
2128
+ const result = await client.send(new ListObjectsV2Command({
2129
+ Bucket: bucket,
2130
+ Prefix: prefix || void 0,
2131
+ Delimiter: delimiter,
2132
+ ContinuationToken: continuationToken,
2133
+ MaxKeys: Math.min(1e3, options.maxResults - entries.length)
2134
+ }));
2135
+ for (const cp of result.CommonPrefixes || []) {
2136
+ if (cp.Prefix) entries.push({ key: cp.Prefix, size: 0, mtime: "", isPrefix: true });
2137
+ if (entries.length >= options.maxResults) break;
2138
+ }
2139
+ for (const obj of result.Contents || []) {
2140
+ if (entries.length >= options.maxResults) break;
2141
+ entries.push({
2142
+ key: obj.Key || "",
2143
+ size: obj.Size || 0,
2144
+ mtime: obj.LastModified ? obj.LastModified.toISOString() : "",
2145
+ isPrefix: false
2146
+ });
2147
+ }
2148
+ continuationToken = result.IsTruncated ? result.NextContinuationToken : void 0;
2149
+ } while (continuationToken && entries.length < options.maxResults);
2150
+ return entries;
2151
+ }
2152
+ async function r2GetObject(bucket, key, maxBytes) {
2153
+ const client = getR2Client();
2154
+ const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
2155
+ const size = head.ContentLength || 0;
2156
+ if (size > maxBytes) {
2157
+ throw new Error(`Object too large: ${size} bytes (max ${maxBytes} for inline read; use sftp-write with sourcePath to mirror locally instead, or pass { offset, length } for a ranged read)`);
2158
+ }
2159
+ const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
2160
+ const body = result.Body;
2161
+ if (!body?.transformToString) throw new Error("R2 returned no readable body");
2162
+ return body.transformToString();
2163
+ }
2164
+ async function r2GetObjectRange(bucket, key, range) {
2165
+ const client = getR2Client();
2166
+ const head = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
2167
+ const size = head.ContentLength || 0;
2168
+ if (range.offset >= size && size > 0) throw new Error(`offset ${range.offset} is past end of object (size ${size})`);
2169
+ const MAX = 1048576;
2170
+ const remaining = Math.max(0, size - range.offset);
2171
+ const effectiveLen = range.length !== void 0 ? Math.min(range.length, remaining, MAX) : Math.min(remaining, MAX);
2172
+ const end = range.offset + effectiveLen - 1;
2173
+ const result = await client.send(new GetObjectCommand({
2174
+ Bucket: bucket,
2175
+ Key: key,
2176
+ Range: `bytes=${range.offset}-${end}`
2177
+ }));
2178
+ const body = result.Body;
2179
+ if (!body?.transformToString) throw new Error("R2 returned no readable body");
2180
+ const text = await body.transformToString();
2181
+ const header = `# range: bytes ${range.offset}-${end} of ${size} (${effectiveLen} bytes)`;
2182
+ return `${header}
2183
+ ${text}`;
2184
+ }
2185
+ async function r2PutObject(bucket, key, body, contentLength) {
2186
+ const client = getR2Client();
2187
+ await client.send(new PutObjectCommand({
2188
+ Bucket: bucket,
2189
+ Key: key,
2190
+ Body: body,
2191
+ ContentLength: contentLength,
2192
+ ContentType: "application/octet-stream"
2193
+ }));
2194
+ }
2195
+ async function r2DeleteObject(bucket, key) {
2196
+ const client = getR2Client();
2197
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
2198
+ }
2199
+ async function r2DeletePrefix(bucket, prefix) {
2200
+ const client = getR2Client();
2201
+ let deleted = 0;
2202
+ let continuationToken;
2203
+ do {
2204
+ const list = await client.send(new ListObjectsV2Command({
2205
+ Bucket: bucket,
2206
+ Prefix: prefix,
2207
+ ContinuationToken: continuationToken,
2208
+ MaxKeys: 1e3
2209
+ }));
2210
+ const keys = (list.Contents || []).map((o) => o.Key).filter((k) => !!k);
2211
+ if (keys.length === 0) break;
2212
+ await client.send(new DeleteObjectsCommand({
2213
+ Bucket: bucket,
2214
+ Delete: { Objects: keys.map((k) => ({ Key: k })), Quiet: true }
2215
+ }));
2216
+ deleted += keys.length;
2217
+ continuationToken = list.IsTruncated ? list.NextContinuationToken : void 0;
2218
+ } while (continuationToken);
2219
+ return deleted;
2220
+ }
2221
+ var R2_MULTIPART_THRESHOLD = 4.5 * 1024 * 1024 * 1024;
2222
+ var R2_MULTIPART_PART_SIZE = 100 * 1024 * 1024;
2223
+ async function r2PutObjectMultipart(bucket, key, localPath, totalBytes, onProgress) {
2224
+ const client = getR2Client();
2225
+ const create = await client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key }));
2226
+ const uploadId = create.UploadId;
2227
+ if (!uploadId) throw new Error("R2 returned no UploadId for multipart create");
2228
+ const parts = [];
2229
+ try {
2230
+ let partNumber = 1;
2231
+ let uploaded = 0;
2232
+ let offset = 0;
2233
+ while (offset < totalBytes) {
2234
+ const end = Math.min(offset + R2_MULTIPART_PART_SIZE, totalBytes);
2235
+ const chunk = await new Promise((resolve, reject) => {
2236
+ const chunks = [];
2237
+ const rs = createReadStream(localPath, { start: offset, end: end - 1 });
2238
+ rs.on("data", (c) => {
2239
+ chunks.push(typeof c === "string" ? Buffer.from(c) : c);
2240
+ });
2241
+ rs.on("end", () => resolve(Buffer.concat(chunks.map((b) => new Uint8Array(b)))));
2242
+ rs.on("error", reject);
2243
+ });
2244
+ const partResult = await client.send(new UploadPartCommand({
2245
+ Bucket: bucket,
2246
+ Key: key,
2247
+ UploadId: uploadId,
2248
+ PartNumber: partNumber,
2249
+ Body: chunk,
2250
+ ContentLength: chunk.length
2251
+ }));
2252
+ if (!partResult.ETag) throw new Error(`Part ${partNumber} returned no ETag`);
2253
+ parts.push({ PartNumber: partNumber, ETag: partResult.ETag });
2254
+ uploaded += chunk.length;
2255
+ onProgress?.(uploaded);
2256
+ offset = end;
2257
+ partNumber++;
2258
+ }
2259
+ await client.send(new CompleteMultipartUploadCommand({
2260
+ Bucket: bucket,
2261
+ Key: key,
2262
+ UploadId: uploadId,
2263
+ MultipartUpload: { Parts: parts }
2264
+ }));
2265
+ } catch (e) {
2266
+ try {
2267
+ await client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId }));
2268
+ } catch {
2269
+ }
2270
+ throw e;
2271
+ }
2272
+ }
2106
2273
  function sanitizePath(path) {
2107
2274
  let normalized = path.replace(/\\/g, "/").replace(/\0/g, "");
2108
2275
  const parts = normalized.split("/");
@@ -2146,7 +2313,7 @@ async function sftpReaddir(opts, dirPath, proxy, options) {
2146
2313
  return await new Promise((resolve) => {
2147
2314
  const timer = setTimeout(() => {
2148
2315
  cleanup?.();
2149
- resolve("Error: timeout");
2316
+ resolve({ entries: [], truncated: false, error: "timeout" });
2150
2317
  cleanup = void 0;
2151
2318
  }, 6e4);
2152
2319
  client.sftp((err, sftp) => {
@@ -2154,32 +2321,33 @@ async function sftpReaddir(opts, dirPath, proxy, options) {
2154
2321
  clearTimeout(timer);
2155
2322
  cleanup?.();
2156
2323
  cleanup = void 0;
2157
- resolve(`Error: ${err.message}`);
2324
+ resolve({ entries: [], truncated: false, error: err.message });
2158
2325
  return;
2159
2326
  }
2160
- const lines = [];
2327
+ const entries = [];
2161
2328
  let truncated = false;
2329
+ const errors = [];
2162
2330
  const readOne = (path, depth) => new Promise((resolveOne) => {
2163
2331
  sftp.readdir(path, (err2, list) => {
2164
2332
  if (err2) {
2165
- lines.push(`! error reading ${path}: ${err2.message}`);
2333
+ errors.push(`error reading ${path}: ${err2.message}`);
2166
2334
  return resolveOne();
2167
2335
  }
2168
2336
  const subdirs = [];
2169
2337
  for (const item of list) {
2170
- if (lines.length >= maxResults) {
2338
+ if (entries.length >= maxResults) {
2171
2339
  truncated = true;
2172
2340
  break;
2173
2341
  }
2174
2342
  const mode = item.attrs.mode || 0;
2175
2343
  const isDir = (mode & 61440) === 16384;
2176
2344
  const size = item.attrs.size || 0;
2177
- const mtime = item.attrs.mtime ? new Date(item.attrs.mtime * 1e3).toISOString() : "";
2345
+ const mtimeMs = item.attrs.mtime ? item.attrs.mtime * 1e3 : 0;
2346
+ const mtime = mtimeMs ? new Date(mtimeMs).toISOString() : "";
2178
2347
  const fullPath = path === "/" ? `/${item.filename}` : `${path}/${item.filename}`;
2179
2348
  const include = !matcher || matcher.test(item.filename);
2180
2349
  if (include) {
2181
- const display = recursive ? fullPath : item.filename;
2182
- lines.push(`${isDir ? "d" : "-"} ${String(size).padStart(10)} ${mtime} ${display}`);
2350
+ entries.push({ kind: isDir ? "d" : "-", size, mtime, mtimeMs, path: recursive ? fullPath : item.filename });
2183
2351
  }
2184
2352
  if (isDir && recursive && depth < maxDepth) subdirs.push(fullPath);
2185
2353
  }
@@ -2197,18 +2365,20 @@ async function sftpReaddir(opts, dirPath, proxy, options) {
2197
2365
  clearTimeout(timer);
2198
2366
  cleanup?.();
2199
2367
  cleanup = void 0;
2200
- if (truncated) lines.push(`... (truncated at ${maxResults} entries; raise maxResults or narrow path/pattern)`);
2201
- resolve(lines.length ? lines.join("\n") : "No entries");
2368
+ resolve({ entries, truncated, error: errors.length ? errors.join("; ") : void 0 });
2202
2369
  });
2203
2370
  });
2204
2371
  });
2205
2372
  } catch (e) {
2206
2373
  cleanup?.();
2207
- return `Error: ${e.message}`;
2374
+ return { entries: [], truncated: false, error: e.message };
2208
2375
  }
2209
2376
  }
2210
- async function sftpRead(opts, filePath, proxy) {
2377
+ async function sftpRead(opts, filePath, proxy, options) {
2211
2378
  const safe = sanitizePath(filePath);
2379
+ const offset = Math.max(0, Math.floor(options?.offset ?? 0));
2380
+ const MAX = 1048576;
2381
+ const requestedLen = options?.length !== void 0 ? Math.max(0, Math.floor(options.length)) : void 0;
2212
2382
  let cleanup;
2213
2383
  try {
2214
2384
  const { client, cleanup: c } = await connectSshClient(opts, proxy, 6e4);
@@ -2235,21 +2405,39 @@ async function sftpRead(opts, filePath, proxy) {
2235
2405
  resolve(`Error: ${err2.message}`);
2236
2406
  return;
2237
2407
  }
2238
- if ((stats.size || 0) > 1048576) {
2408
+ const total = stats.size || 0;
2409
+ if (offset >= total && total > 0) {
2239
2410
  clearTimeout(timer);
2240
2411
  cleanup?.();
2241
2412
  cleanup = void 0;
2242
- resolve(`Error: file too large (${stats.size} bytes, max 1MB)`);
2413
+ resolve(`Error: offset ${offset} is past end of file (size ${total})`);
2414
+ return;
2415
+ }
2416
+ const remaining = Math.max(0, total - offset);
2417
+ const effectiveLen = requestedLen !== void 0 ? Math.min(requestedLen, remaining, MAX) : Math.min(remaining, MAX);
2418
+ const isWholeFileRequest = options === void 0 || offset === 0 && requestedLen === void 0;
2419
+ if (isWholeFileRequest && total > MAX) {
2420
+ clearTimeout(timer);
2421
+ cleanup?.();
2422
+ cleanup = void 0;
2423
+ resolve(`Error: file too large (${total} bytes, max ${MAX}). Use { offset, length } for ranged reads.`);
2243
2424
  return;
2244
2425
  }
2245
2426
  const chunks = [];
2246
- const rs = sftp.createReadStream(safe);
2427
+ const rs = sftp.createReadStream(safe, { start: offset, end: offset + effectiveLen - 1 });
2247
2428
  rs.on("data", (ch) => chunks.push(ch));
2248
2429
  rs.on("end", () => {
2249
2430
  clearTimeout(timer);
2250
2431
  cleanup?.();
2251
2432
  cleanup = void 0;
2252
- resolve(Buffer.concat(chunks.map((ch) => new Uint8Array(ch))).toString("utf-8"));
2433
+ const text = Buffer.concat(chunks.map((ch) => new Uint8Array(ch))).toString("utf-8");
2434
+ if (!isWholeFileRequest) {
2435
+ const header = `# range: bytes ${offset}-${offset + effectiveLen - 1} of ${total} (${effectiveLen} bytes)`;
2436
+ resolve(`${header}
2437
+ ${text}`);
2438
+ } else {
2439
+ resolve(text);
2440
+ }
2253
2441
  });
2254
2442
  rs.on("error", (e) => {
2255
2443
  clearTimeout(timer);
@@ -2276,9 +2464,10 @@ function formatBytes(bytes) {
2276
2464
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
2277
2465
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
2278
2466
  }
2279
- async function sftpWrite(opts, filePath, input, proxy) {
2467
+ async function sftpWrite(opts, filePath, input, proxy, meta) {
2280
2468
  const safe = sanitizePath(filePath);
2281
2469
  assertWritablePath(safe);
2470
+ const fileMode = meta?.fileMode !== void 0 ? meta.fileMode & 4095 : 420;
2282
2471
  let mode;
2283
2472
  let inlineBuffer;
2284
2473
  let localPath;
@@ -2344,12 +2533,19 @@ async function sftpWrite(opts, filePath, input, proxy) {
2344
2533
  }
2345
2534
  client.sftp((err, sftp) => {
2346
2535
  if (err) return finish(`Error: ${err.message}`);
2536
+ const applyMtime = (then) => {
2537
+ if (meta?.mtimeMs === void 0) return then("");
2538
+ const secs = Math.floor(meta.mtimeMs / 1e3);
2539
+ sftp.setstat(safe, { atime: secs, mtime: secs }, (statErr) => {
2540
+ then(statErr ? ` (mtime set failed: ${statErr.message})` : ` (mtime=${new Date(meta.mtimeMs).toISOString()})`);
2541
+ });
2542
+ };
2347
2543
  if (mode === "content") {
2348
- const ws = sftp.createWriteStream(safe, { mode: 420 });
2544
+ const ws = sftp.createWriteStream(safe, { mode: fileMode });
2349
2545
  ws.on("error", (e) => finish(`Error: ${e.message}`));
2350
2546
  ws.on("close", () => {
2351
2547
  const elapsed = Date.now() - startedAt;
2352
- finish(`Written ${expectedBytes} bytes to ${safe} in ${elapsed}ms`);
2548
+ applyMtime((suffix) => finish(`Written ${expectedBytes} bytes to ${safe} in ${elapsed}ms (mode=0o${fileMode.toString(8)})${suffix}`));
2353
2549
  });
2354
2550
  ws.end(inlineBuffer);
2355
2551
  return;
@@ -2361,7 +2557,7 @@ async function sftpWrite(opts, filePath, input, proxy) {
2361
2557
  {
2362
2558
  concurrency: SFTP_FASTPUT_CONCURRENCY,
2363
2559
  chunkSize: SFTP_FASTPUT_CHUNK_SIZE,
2364
- mode: 420,
2560
+ mode: fileMode,
2365
2561
  fileSize: expectedBytes,
2366
2562
  step: (transferred) => {
2367
2563
  bytesWritten = transferred;
@@ -2378,9 +2574,9 @@ async function sftpWrite(opts, filePath, input, proxy) {
2378
2574
  if (fpErr) return finish(`Error: ${fpErr.message}`);
2379
2575
  const elapsed = Date.now() - startedAt;
2380
2576
  const mbps = expectedBytes / (1024 * 1024) / Math.max(1e-3, elapsed / 1e3);
2381
- finish(
2382
- `Uploaded ${formatBytes(expectedBytes)} from ${localPath} to ${safe} in ${(elapsed / 1e3).toFixed(1)}s (${mbps.toFixed(1)} MB/s)`
2383
- );
2577
+ applyMtime((suffix) => finish(
2578
+ `Uploaded ${formatBytes(expectedBytes)} from ${localPath} to ${safe} in ${(elapsed / 1e3).toFixed(1)}s (${mbps.toFixed(1)} MB/s, mode=0o${fileMode.toString(8)})${suffix}`
2579
+ ));
2384
2580
  }
2385
2581
  );
2386
2582
  });
@@ -2390,9 +2586,10 @@ async function sftpWrite(opts, filePath, input, proxy) {
2390
2586
  return `Error: ${e instanceof Error ? e.message : String(e)}`;
2391
2587
  }
2392
2588
  }
2393
- async function sftpDelete(opts, filePath, proxy) {
2589
+ async function sftpDelete(opts, filePath, proxy, options) {
2394
2590
  const safe = sanitizePath(filePath);
2395
2591
  assertWritablePath(safe);
2592
+ const recursive = options?.recursive === true;
2396
2593
  let cleanup;
2397
2594
  try {
2398
2595
  const { client, cleanup: c } = await connectSshClient(opts, proxy, 3e4);
@@ -2402,7 +2599,7 @@ async function sftpDelete(opts, filePath, proxy) {
2402
2599
  cleanup?.();
2403
2600
  resolve("Error: timeout");
2404
2601
  cleanup = void 0;
2405
- }, 3e4);
2602
+ }, 6e4);
2406
2603
  client.sftp((err, sftp) => {
2407
2604
  if (err) {
2408
2605
  clearTimeout(timer);
@@ -2411,6 +2608,40 @@ async function sftpDelete(opts, filePath, proxy) {
2411
2608
  resolve(`Error: ${err.message}`);
2412
2609
  return;
2413
2610
  }
2611
+ if (recursive) {
2612
+ const removeTree = async (target) => {
2613
+ return new Promise((resolveTree) => {
2614
+ sftp.stat(target, (statErr, stats) => {
2615
+ if (statErr) return resolveTree({ files: 0, dirs: 0 });
2616
+ const mode = stats.mode || 0;
2617
+ const isDir = (mode & 61440) === 16384;
2618
+ if (!isDir) {
2619
+ sftp.unlink(target, () => resolveTree({ files: 1, dirs: 0 }));
2620
+ return;
2621
+ }
2622
+ sftp.readdir(target, async (rdErr, list) => {
2623
+ if (rdErr) return resolveTree({ files: 0, dirs: 0 });
2624
+ let files = 0;
2625
+ let dirs = 0;
2626
+ for (const item of list) {
2627
+ const child = target === "/" ? `/${item.filename}` : `${target}/${item.filename}`;
2628
+ const r = await removeTree(child);
2629
+ files += r.files;
2630
+ dirs += r.dirs;
2631
+ }
2632
+ sftp.rmdir(target, () => resolveTree({ files, dirs: dirs + 1 }));
2633
+ });
2634
+ });
2635
+ });
2636
+ };
2637
+ removeTree(safe).then(({ files, dirs }) => {
2638
+ clearTimeout(timer);
2639
+ cleanup?.();
2640
+ cleanup = void 0;
2641
+ resolve(`Removed ${safe} recursively (${files} files, ${dirs} directories)`);
2642
+ });
2643
+ return;
2644
+ }
2414
2645
  sftp.unlink(safe, (unlinkErr) => {
2415
2646
  if (unlinkErr) {
2416
2647
  sftp.rmdir(safe, (rmdirErr) => {
@@ -2617,110 +2848,135 @@ async function mijnhostFetch(path, options = {}) {
2617
2848
  var TOOLS = [
2618
2849
  {
2619
2850
  name: "list-servers",
2620
- description: "List all SSH servers you have access to. Returns id, name, hostname, and tags for each server.",
2621
- inputSchema: { type: "object", properties: {}, required: [] }
2851
+ description: "List all SSH servers you have access to. Returns id, name, hostname, tags, and os_type per server. Pass `includeStats: true` to also probe each server in parallel for container count, disk-free, and uptime (skips unreachable hosts gracefully).",
2852
+ inputSchema: {
2853
+ type: "object",
2854
+ properties: {
2855
+ includeStats: { type: "boolean", description: "When true, probe each server (in parallel, with timeout) for container count, disk-free, and uptime." },
2856
+ statsParallelism: { type: "number", description: "Max parallel probes when includeStats is true (default 8, cap 20)." },
2857
+ statsTimeoutMs: { type: "number", description: "Per-server probe timeout in ms when includeStats is true (default 6000, range 1000-30000)." }
2858
+ }
2859
+ }
2622
2860
  },
2623
2861
  {
2624
2862
  name: "ssh-execute",
2625
- description: 'Execute a command on a remote server via SSH. OS-aware: automatically wraps in bash on linux servers and `powershell -EncodedCommand` (UTF-16LE base64) on windows servers, 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"` (raw shell string, OS-dispatched but caller-quoted).\n- Safe: `command` + `args[]`, e.g. `command: "mysql"`, `args: ["-u", "root", "-p$tr@nge#pwd", "-e", "SELECT 1"]` \u2014 every arg is quoted/encoded for the target OS.\n`stdin` lets you pipe data into the remote process (queries, scripts, secrets) without putting it on the command line.',
2863
+ description: 'Execute a command on a remote server via SSH. OS-aware: bash on linux, `powershell -EncodedCommand` (UTF-16LE base64) on windows, so $, #, quotes, spaces inside `args` are never re-interpreted by a shell. Some dangerous commands are blocked. Use `list-servers` first to see each server\'s os_type.\nTwo ways to invoke (use `args` for anything with passwords or special chars):\n- Quick: `command` only, e.g. `command: "df -h"`.\n- Safe: `command` + `args[]`, e.g. `command: "mysql"`, `args: ["-u","root","-p$tr@nge#pwd","-e","SELECT 1"]`.\n`stdin` pipes data into the remote process (queries, scripts, secrets) without putting it on the command line.\n`cwd` sets the working directory (auto `cd` on Linux / `Set-Location` on Windows).\nFan-out: pass `serverIds: [id1, id2, ...]` (instead of `serverId`) to run the same command across multiple servers in parallel. Each server\'s output is grouped under its own `=== name (os, exit N) ===` header.',
2626
2864
  inputSchema: {
2627
2865
  type: "object",
2628
2866
  properties: {
2629
- serverId: { type: "string", description: "UUID of the SSH server" },
2867
+ serverId: { type: "string", description: "UUID of one SSH server. Mutually exclusive with serverIds." },
2868
+ serverIds: { type: "array", items: { type: "string" }, description: "Fan-out: list of server UUIDs to run the command on in parallel. Mutually exclusive with serverId." },
2630
2869
  command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required.' },
2631
2870
  args: { type: "array", items: { type: "string" }, description: "Optional argv list. When provided, each entry is safely quoted/encoded for the target OS." },
2632
- stdin: { type: "string", description: "Optional data piped to the remote process stdin (use for SQL queries, scripts, secrets \u2014 anything you do NOT want on the command line)." },
2871
+ stdin: { type: "string", description: "Optional data piped to the remote process stdin." },
2872
+ cwd: { type: "string", description: "Working directory on the remote (cd <cwd> on Linux, Set-Location on Windows)." },
2633
2873
  shell: { type: "string", enum: ["auto", "bash", "powershell"], description: `Override shell selection (default "auto" uses the server's os_type).` },
2634
- timeout: { type: "number", description: "Timeout in milliseconds (default: 60000)" }
2874
+ parallelism: { type: "number", description: "Max concurrent SSH sessions when using serverIds[] (default 5, max 20)." },
2875
+ timeout: { type: "number", description: "Per-server timeout in milliseconds (default: 60000)" }
2635
2876
  },
2636
- required: ["serverId", "command"]
2877
+ required: ["command"]
2637
2878
  }
2638
2879
  },
2639
2880
  {
2640
2881
  name: "sftp-list",
2641
- description: 'List files and directories on a remote server via SFTP. Supports recursive traversal and glob filtering, eliminating the need to fall back on `ssh-execute "find ..."`.',
2882
+ description: 'List files in remote storage. Two backends (provide exactly one of `serverId` or `bucket`):\n- `serverId`: list a directory on an SSH server via SFTP. Supports recursive traversal, depth cap, and glob filtering (no need for `ssh-execute "find ..."`).\n- `bucket`: list objects in a Cloudflare R2 bucket. `path` becomes the key prefix. Default folder mode groups by `/` delimiter; pass `recursive: true` for a flat listing of all keys under the prefix. Requires R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY env vars on the MCP.\n\nSorting/summary applies to both backends: `sortBy` (name|size|mtime) + `reverse`. `summary: true` returns just totals (file count, dir count, total bytes) \u2014 useful as a cheap pre-flight before pulling a full listing.',
2642
2883
  inputSchema: {
2643
2884
  type: "object",
2644
2885
  properties: {
2645
- serverId: { type: "string", description: "UUID of the SSH server" },
2646
- path: { type: "string", description: "Directory path to list (default: /)" },
2647
- recursive: { type: "boolean", description: "Walk into subdirectories (default false)" },
2648
- maxDepth: { type: "number", description: "Maximum recursion depth (1-20, default 5)" },
2649
- pattern: { type: "string", description: 'Glob pattern to filter filenames (e.g. "*.conf", "wp-*.php"). Matches basename only.' },
2650
- maxResults: { type: "number", description: "Cap total entries returned (default 5000, max 50000)" }
2651
- },
2652
- required: ["serverId"]
2886
+ serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
2887
+ bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
2888
+ path: { type: "string", description: "Directory path (SSH) or key prefix (R2). Default: /" },
2889
+ recursive: { type: "boolean", description: "Walk into subdirectories / list all keys flat (default false)" },
2890
+ maxDepth: { type: "number", description: "Maximum recursion depth for SSH mode (1-20, default 5). Ignored for R2." },
2891
+ pattern: { type: "string", description: 'Glob pattern to filter basenames (e.g. "*.conf", "wp-*.php").' },
2892
+ maxResults: { type: "number", description: "Cap total entries returned (default 5000, max 50000)" },
2893
+ sortBy: { type: "string", enum: ["name", "size", "mtime"], description: "Sort order (default `name`). Directories always come first within each ordering." },
2894
+ reverse: { type: "boolean", description: "Reverse sort direction (e.g. largest-first or newest-first)." },
2895
+ summary: { type: "boolean", description: "Return only totals (file count, directory count, total bytes) instead of the full listing." }
2896
+ }
2653
2897
  }
2654
2898
  },
2655
2899
  {
2656
2900
  name: "sftp-read",
2657
- description: "Read the contents of a text file on a remote server via SFTP (max 1MB).",
2901
+ description: "Read a text file from remote storage (max 1 MiB). Two backends (provide exactly one of `serverId` or `bucket`):\n- `serverId`: read from an SSH server via SFTP.\n- `bucket`: read an object from a Cloudflare R2 bucket. `path` is the object key.\n\nUse `offset` and/or `length` for ranged reads \u2014 perfect for sampling a slice of a multi-MB log/dump without exceeding the 1 MiB inline limit. Ranged reads include a `# range: bytes A-B of TOTAL` header so you know what window you got.",
2658
2902
  inputSchema: {
2659
2903
  type: "object",
2660
2904
  properties: {
2661
- serverId: { type: "string", description: "UUID of the SSH server" },
2662
- path: { type: "string", description: "File path to read" }
2905
+ serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
2906
+ bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
2907
+ path: { type: "string", description: "File path (SSH) or object key (R2)" },
2908
+ offset: { type: "number", description: "Byte offset to start reading from (0-indexed). Triggers ranged read mode." },
2909
+ length: { type: "number", description: "Number of bytes to read (capped at 1 MiB). Defaults to remaining-from-offset when omitted." }
2663
2910
  },
2664
- required: ["serverId", "path"]
2911
+ required: ["path"]
2665
2912
  }
2666
2913
  },
2667
2914
  {
2668
2915
  name: "sftp-write",
2669
- description: "Write a file to a remote server via SFTP. Two modes (provide exactly one of `content` or `sourcePath`):\n- `content` (string): inline UTF-8 text. Best for configs, scripts, small JSON. Practical max ~1 MB.\n- `sourcePath` (absolute local path): streams a local file with ssh2 fastPut (64 parallel pipelined writes, 64 KiB chunks). Handles GB-scale files (zips, dumps, builds) without going through the LLM. Only usable when the MCP server runs locally on your machine (i.e. not via --proxy-url).\nProtected system paths on the remote are blocked.",
2916
+ description: "Write a file to remote storage. Two backends (provide exactly one of `serverId` or `bucket`) and two source modes (provide exactly one of `content` or `sourcePath`):\n\nBackends:\n- `serverId`: SSH server via SFTP. Protected system paths blocked. Optional `mode` sets POSIX permission bits (e.g. 0o755 for executables) and `mtime` sets the modification time.\n- `bucket`: Cloudflare R2 bucket (S3-compatible). Single PUT for files <4.5 GB, automatic multipart upload (100 MB parts) for larger files up to ~5 TB. `mode`/`mtime` are ignored on R2.\n\nSources:\n- `content` (string): inline UTF-8 text. Practical max ~1 MB.\n- `sourcePath` (absolute local path): streams the local file. SSH uses ssh2 fastPut (64 parallel pipelined writes); R2 uses streamed PUT or multipart depending on size. Both handle GB-scale files. Only usable when the MCP runs locally.",
2670
2917
  inputSchema: {
2671
2918
  type: "object",
2672
2919
  properties: {
2673
- serverId: { type: "string", description: "UUID of the SSH server" },
2674
- path: { type: "string", description: "Remote file path to write" },
2920
+ serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
2921
+ bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
2922
+ path: { type: "string", description: "Remote file path (SSH) or object key (R2)" },
2675
2923
  content: { type: "string", description: "Inline UTF-8 file content (mutually exclusive with sourcePath)" },
2676
- sourcePath: { type: "string", description: "Absolute local file path to upload via fastPut (mutually exclusive with content). Use for files >1 MB or any binary." }
2924
+ sourcePath: { type: "string", description: "Absolute local file path to upload (mutually exclusive with content). Use for files >1 MB or any binary." },
2925
+ mode: { oneOf: [{ type: "number" }, { type: "string" }], description: 'POSIX file permission bits (SSH only). Number (0o755 / 493) or octal string ("755" / "0o755"). Default 0o644.' },
2926
+ mtime: { oneOf: [{ type: "number" }, { type: "string" }], description: "File modification time (SSH only). ms-since-epoch number or ISO-8601 date string." }
2677
2927
  },
2678
- required: ["serverId", "path"]
2928
+ required: ["path"]
2679
2929
  }
2680
2930
  },
2681
2931
  {
2682
2932
  name: "sftp-delete",
2683
- description: "Delete a file or empty directory on a remote server via SFTP. Protected system paths are blocked.",
2933
+ description: "Delete a file (or directory tree) from remote storage. Two backends (provide exactly one of `serverId` or `bucket`):\n- `serverId`: delete a file or empty directory on an SSH server via SFTP. Protected system paths blocked. With `recursive: true` walks the tree depth-first via SFTP and removes files + empty dirs from the bottom up (works on Windows OpenSSH too \u2014 no shell tricks).\n- `bucket`: delete an object from a Cloudflare R2 bucket. `path` is the object key. With `recursive: true`, treats `path` as a key prefix and batch-deletes every object beneath it (R2 caps at 1000 keys per call; we loop pages automatically).",
2684
2934
  inputSchema: {
2685
2935
  type: "object",
2686
2936
  properties: {
2687
- serverId: { type: "string", description: "UUID of the SSH server" },
2688
- path: { type: "string", description: "File or directory path to delete" }
2937
+ serverId: { type: "string", description: "UUID of the SSH server (mutually exclusive with bucket)" },
2938
+ bucket: { type: "string", description: "Cloudflare R2 bucket name (mutually exclusive with serverId)" },
2939
+ path: { type: "string", description: "File path / object key (or directory / key prefix when recursive)" },
2940
+ recursive: { type: "boolean", description: "Recursively remove a directory tree (SSH) or all objects under a key prefix (R2)." }
2689
2941
  },
2690
- required: ["serverId", "path"]
2942
+ required: ["path"]
2691
2943
  }
2692
2944
  },
2693
2945
  {
2694
2946
  name: "docker-list",
2695
- description: "List Docker containers on a remote server. Adds the docker-compose project label as the last column so you can immediately see which compose project a container belongs to.",
2947
+ description: "List Docker containers on a remote server. Sorted by compose project then name, with a HEALTH column (healthy / unhealthy / starting / no-check). Filter by `nameFilter` (string OR array of substring/glob entries \u2014 passes if ANY entry matches), restrict to compose containers with `composeOnly`, or group output per project with `groupByProject`.",
2696
2948
  inputSchema: {
2697
2949
  type: "object",
2698
2950
  properties: {
2699
2951
  serverId: { type: "string", description: "UUID of the SSH server" },
2700
2952
  format: { type: "string", enum: ["table", "json"], description: "Output format: human table (default) or NDJSON (one JSON object per line, includes labels)." },
2701
- composeOnly: { type: "boolean", description: "Only show containers that have a docker-compose project label." }
2953
+ composeOnly: { type: "boolean", description: "Only show containers that have a docker-compose project label." },
2954
+ nameFilter: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Filter by container name. Single entry or array; each entry is glob (* or ?) or substring. A row passes when ANY entry matches." },
2955
+ groupByProject: { type: "boolean", description: "Group output per compose project with a `=== project (count) ===` header." }
2702
2956
  },
2703
2957
  required: ["serverId"]
2704
2958
  }
2705
2959
  },
2706
2960
  {
2707
2961
  name: "docker-logs",
2708
- description: "Get logs from a Docker container. Supports time-window (`since`) and server-side `grep` to keep responses small. Always merges stderr into stdout.",
2962
+ description: 'Get logs from one or more Docker containers. `containerName` accepts a string OR an array \u2014 multi-container output is line-prefixed with `[name]` so you can read related services together (e.g. `["refront-rest-1", "refront-kong-1", "refront-db-1"]`). Supports time-window (`since`), server-side `grep`, line-count (`tail`), and real-time follow (`followSeconds`). Always merges stderr into stdout.',
2709
2963
  inputSchema: {
2710
2964
  type: "object",
2711
2965
  properties: {
2712
2966
  serverId: { type: "string", description: "UUID of the SSH server" },
2713
- containerName: { type: "string", description: "Container name or ID" },
2714
- lines: { type: "number", description: "Number of log lines to retrieve (default: 100)" },
2967
+ containerName: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Single container name/ID or an array for multi-container tail. Multi mode prefixes each line with [name]." },
2968
+ tail: { type: "number", description: "Number of recent log lines per container (default 100). Alias: `lines`." },
2969
+ lines: { type: "number", description: "Deprecated alias for `tail`. Prefer `tail`." },
2715
2970
  since: { type: "string", description: 'Time window, e.g. "10m", "2h", "24h", or an absolute "2026-05-09T10:00:00".' },
2716
- grep: { type: "string", description: "Case-insensitive regex/literal filter applied server-side (saves tokens for noisy containers)." }
2971
+ grep: { type: "string", description: "Case-insensitive regex/literal filter applied server-side (saves tokens for noisy containers)." },
2972
+ followSeconds: { type: "number", description: "When >0, stream new log lines for N seconds via `docker logs -f`, then exit (max 300). Multi-container follow runs all streams in parallel." }
2717
2973
  },
2718
2974
  required: ["serverId", "containerName"]
2719
2975
  }
2720
2976
  },
2721
2977
  {
2722
2978
  name: "docker-exec",
2723
- description: 'Run a command inside a running Docker container. `args[]` are quoted safely (no shell-escape hell with $ or quotes). Optional `stdin` pipes data into the container process (for SQL, scripts, etc.). Use this instead of `ssh-execute "docker exec ..."`.',
2979
+ description: 'Run a command inside a running Docker container. `args[]` are quoted safely (no shell-escape hell with $ or quotes). Optional `stdin` pipes data into the container process (for SQL, scripts, etc.). Pass `interactive: true` to allocate a TTY (`docker exec -it`) so commands like `psql`/`mysql`/`bash` produce human-friendly output. Use this instead of `ssh-execute "docker exec ..."`.',
2724
2980
  inputSchema: {
2725
2981
  type: "object",
2726
2982
  properties: {
@@ -2729,6 +2985,7 @@ var TOOLS = [
2729
2985
  command: { type: "string", description: 'Program to run inside the container (e.g. "psql", "wp", "node").' },
2730
2986
  args: { type: "array", items: { type: "string" }, description: "Argument list, each safely quoted." },
2731
2987
  stdin: { type: "string", description: "Optional data piped to the container process stdin." },
2988
+ interactive: { type: "boolean", description: "Allocate a TTY (`docker exec -it`). Useful for tools that probe isatty(stdout) \u2014 collapses stderr into stdout when enabled." },
2732
2989
  workdir: { type: "string", description: "Working directory inside the container (-w)." },
2733
2990
  user: { type: "string", description: "User to run as inside the container (-u)." },
2734
2991
  timeout: { type: "number", description: "Timeout in milliseconds (default: 60000)" }
@@ -2856,29 +3113,6 @@ var TOOLS = [
2856
3113
  description: "List all domains from the mijn.host account. Returns domain name, status, renewal date, and tags. Requires MIJNHOST_API_KEY.",
2857
3114
  inputSchema: { type: "object", properties: {}, required: [] }
2858
3115
  },
2859
- {
2860
- name: "domain-get",
2861
- description: "Get detailed information about a specific domain: status, renewal date, lock state, managed DNS, DNSSEC, nameservers, contact handles, and messages.",
2862
- inputSchema: {
2863
- type: "object",
2864
- properties: {
2865
- domain: { type: "string", description: "Domain name (e.g. example.com)" }
2866
- },
2867
- required: ["domain"]
2868
- }
2869
- },
2870
- {
2871
- name: "domain-update-ns",
2872
- description: 'Update nameservers for a domain. Use alias "default-mijnhost" to reset to mijn.host defaults. Pushes the change to the domain registry.',
2873
- inputSchema: {
2874
- type: "object",
2875
- properties: {
2876
- domain: { type: "string", description: "Domain name (e.g. example.com)" },
2877
- nameserver: { type: "string", description: 'Nameserver profile alias (e.g. "default-mijnhost")' }
2878
- },
2879
- required: ["domain", "nameserver"]
2880
- }
2881
- },
2882
3116
  {
2883
3117
  name: "dns-list",
2884
3118
  description: "List all DNS records for a domain. Returns type (A, AAAA, CNAME, MX, TXT, etc.), name, value, and TTL for each record.",
@@ -2940,7 +3174,7 @@ var TOOLS = [
2940
3174
  // ----- Agent Reporting -----
2941
3175
  ...AGENT_TOOLS
2942
3176
  ];
2943
- var MCP_VERSION = "3.1.1";
3177
+ var MCP_VERSION = "3.7.0";
2944
3178
  async function handleListTools() {
2945
3179
  if (!authContext) return { tools: TOOLS };
2946
3180
  const accessible = TOOLS.filter((tool) => {
@@ -2992,10 +3226,57 @@ async function executeToolCall(name, a, _serverId) {
2992
3226
  }
2993
3227
  const { data, error } = await query;
2994
3228
  if (error) throw new Error(error.message);
2995
- const lines = (data || []).map((s) => {
3229
+ const includeStats = a.includeStats === true;
3230
+ const servers = data || [];
3231
+ let statsByServer = /* @__PURE__ */ new Map();
3232
+ if (includeStats && servers.length > 0) {
3233
+ const parallelism = Math.max(1, Math.min(20, Number(a.statsParallelism) || 8));
3234
+ const probeTimeoutMs = Math.max(1e3, Math.min(3e4, Number(a.statsTimeoutMs) || 6e3));
3235
+ const linuxProbe = `echo "$(docker ps -q 2>/dev/null | wc -l)|$(df -h / 2>/dev/null | awk 'NR==2{print $4}')|$(uptime -p 2>/dev/null || uptime)"`;
3236
+ const windowsProbe = [
3237
+ "$ProgressPreference='SilentlyContinue';",
3238
+ "$c = (docker ps -q 2>$null | Measure-Object).Count;",
3239
+ "$d = try { '{0:N1} GB' -f ((Get-PSDrive C).Free/1GB) } catch { 'N/A' };",
3240
+ "$u = try { (Get-Uptime).ToString() } catch { ((Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime).ToString() };",
3241
+ 'Write-Output "$c|$d|$u"'
3242
+ ].join(" ");
3243
+ const probeOne = async (s) => {
3244
+ try {
3245
+ const { conn, proxy, os } = await getServerConnection(s.id);
3246
+ conn.timeout = probeTimeoutMs;
3247
+ const wrapped = os === "windows" ? buildPowerShellEncodedCommand(windowsProbe, []) : `bash -c ${posixQuote(linuxProbe)}`;
3248
+ const result = await sshExec(conn, wrapped, proxy);
3249
+ if (result.exitCode !== 0) {
3250
+ return [s.id, { reachable: false, error: (result.stderr || result.stdout || "probe failed").trim().slice(0, 120) }];
3251
+ }
3252
+ const parts = result.stdout.trim().split("|");
3253
+ return [s.id, {
3254
+ reachable: true,
3255
+ containers: Number(parts[0]) || 0,
3256
+ diskFreeHuman: (parts[1] || "").trim() || "N/A",
3257
+ uptimeHuman: (parts[2] || "").trim() || "N/A"
3258
+ }];
3259
+ } catch (err) {
3260
+ return [s.id, { reachable: false, error: err instanceof Error ? err.message.slice(0, 120) : String(err).slice(0, 120) }];
3261
+ }
3262
+ };
3263
+ const results = [];
3264
+ for (let i = 0; i < servers.length; i += parallelism) {
3265
+ const batch = servers.slice(i, i + parallelism);
3266
+ const chunk = await Promise.all(batch.map(probeOne));
3267
+ results.push(...chunk);
3268
+ }
3269
+ statsByServer = new Map(results);
3270
+ }
3271
+ const lines = servers.map((s) => {
2996
3272
  const tags = Array.isArray(s.tags) ? s.tags.join(", ") : "";
2997
3273
  const os = s.os_type || "linux";
2998
- return `${s.id} ${s.name} ${s.hostname}:${s.port} ${s.username} [${tags}] ${s.hosted_by || ""} os:${os}`;
3274
+ const base = `${s.id} ${s.name} ${s.hostname}:${s.port} ${s.username} [${tags}] ${s.hosted_by || ""} os:${os}`;
3275
+ if (!includeStats) return base;
3276
+ const st = statsByServer.get(s.id);
3277
+ if (!st) return `${base} stats:skipped`;
3278
+ if (!st.reachable) return `${base} UNREACHABLE (${st.error || "unknown"})`;
3279
+ return `${base} containers:${st.containers} disk_free:${st.diskFreeHuman} uptime:${st.uptimeHuman}`;
2999
3280
  });
3000
3281
  return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No servers found" }] };
3001
3282
  }
@@ -3006,85 +3287,380 @@ async function executeToolCall(name, a, _serverId) {
3006
3287
  const args2 = Array.isArray(a.args) ? a.args.map(String) : void 0;
3007
3288
  const stdin = typeof a.stdin === "string" ? a.stdin : void 0;
3008
3289
  const shellOverride = typeof a.shell === "string" ? a.shell : "auto";
3009
- const { conn, proxy, os } = await getServerConnection(String(a.serverId));
3010
- if (a.timeout) conn.timeout = Number(a.timeout);
3011
- const shell = shellOverride === "auto" ? os === "windows" ? "powershell" : "bash" : shellOverride;
3012
- let finalCmd;
3013
- if (args2 && args2.length > 0) {
3014
- finalCmd = shell === "powershell" ? buildPowerShellEncodedCommand(command, args2) : buildPosixCommand(command, args2);
3015
- } else if (shell === "powershell" && !/^powershell\b/i.test(command.trim())) {
3016
- finalCmd = buildPowerShellEncodedCommand(command, []);
3017
- } else {
3018
- finalCmd = command;
3290
+ const cwd = typeof a.cwd === "string" && a.cwd ? a.cwd : void 0;
3291
+ const timeoutMs = a.timeout ? Number(a.timeout) : void 0;
3292
+ const targetIds = Array.isArray(a.serverIds) && a.serverIds.length > 0 ? a.serverIds.map(String) : a.serverId ? [String(a.serverId)] : [];
3293
+ if (targetIds.length === 0) {
3294
+ return { content: [{ type: "text", text: "Error: pass either `serverId` or `serverIds[]`" }] };
3019
3295
  }
3020
- const result = await sshExec(conn, finalCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
3021
- const output = [`Exit code: ${result.exitCode} (os: ${os}, shell: ${shell})`];
3022
- if (result.stdout) output.push(`--- stdout ---
3296
+ const parallelism = Math.max(1, Math.min(20, Number(a.parallelism) || 5));
3297
+ const runOne = async (serverId) => {
3298
+ const { conn, proxy, os } = await getServerConnection(serverId);
3299
+ if (timeoutMs) conn.timeout = timeoutMs;
3300
+ const shell = shellOverride === "auto" ? os === "windows" ? "powershell" : "bash" : shellOverride;
3301
+ let body;
3302
+ if (args2 && args2.length > 0) {
3303
+ body = shell === "powershell" ? buildPowerShellEncodedCommand(command, args2) : buildPosixCommand(command, args2);
3304
+ } else if (shell === "powershell" && !/^powershell\b/i.test(command.trim())) {
3305
+ body = buildPowerShellEncodedCommand(command, []);
3306
+ } else {
3307
+ body = command;
3308
+ }
3309
+ let finalCmd = body;
3310
+ if (cwd) {
3311
+ if (shell === "bash") {
3312
+ finalCmd = `cd ${posixQuote(cwd)} && ${body}`;
3313
+ } else {
3314
+ const ecMatch = body.match(/-EncodedCommand\s+(\S+)$/);
3315
+ if (ecMatch) {
3316
+ const decoded = Buffer.from(ecMatch[1], "base64").toString("utf16le");
3317
+ const wrapped = `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${decoded}`;
3318
+ const reencoded = Buffer.from(wrapped, "utf16le").toString("base64");
3319
+ finalCmd = body.replace(/-EncodedCommand\s+\S+$/, `-EncodedCommand ${reencoded}`);
3320
+ }
3321
+ }
3322
+ }
3323
+ const result = await sshExec(conn, finalCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
3324
+ let serverName = serverId;
3325
+ try {
3326
+ const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).single();
3327
+ if (data?.name) serverName = data.name;
3328
+ } catch {
3329
+ }
3330
+ return { serverId, serverName, os, shell, result };
3331
+ };
3332
+ if (targetIds.length === 1) {
3333
+ const { os, shell, result } = await runOne(targetIds[0]);
3334
+ const output = [`Exit code: ${result.exitCode} (os: ${os}, shell: ${shell})`];
3335
+ if (result.stdout) output.push(`--- stdout ---
3023
3336
  ${result.stdout}`);
3024
- if (result.stderr) output.push(`--- stderr ---
3337
+ if (result.stderr) output.push(`--- stderr ---
3025
3338
  ${result.stderr}`);
3026
- return { content: [{ type: "text", text: output.join("\n") }] };
3339
+ return { content: [{ type: "text", text: output.join("\n") }] };
3340
+ }
3341
+ const allResults = [];
3342
+ for (let i = 0; i < targetIds.length; i += parallelism) {
3343
+ const batch = targetIds.slice(i, i + parallelism);
3344
+ const settled = await Promise.allSettled(batch.map((id) => runOne(id)));
3345
+ for (let j = 0; j < settled.length; j++) {
3346
+ const s = settled[j];
3347
+ if (s.status === "fulfilled") allResults.push(s.value);
3348
+ else allResults.push({ serverId: batch[j], error: s.reason instanceof Error ? s.reason.message : String(s.reason) });
3349
+ }
3350
+ }
3351
+ const sections = [];
3352
+ let okCount = 0;
3353
+ let failCount = 0;
3354
+ for (const r of allResults) {
3355
+ if ("error" in r) {
3356
+ failCount++;
3357
+ sections.push(`=== ${r.serverId} === !! connection error: ${r.error}`);
3358
+ continue;
3359
+ }
3360
+ if (r.result.exitCode === 0) okCount++;
3361
+ else failCount++;
3362
+ const header = `=== ${r.serverName} (${r.os}, exit ${r.result.exitCode}) ===`;
3363
+ const parts = [header];
3364
+ if (r.result.stdout) parts.push(r.result.stdout.trimEnd());
3365
+ if (r.result.stderr) parts.push(`[stderr] ${r.result.stderr.trimEnd()}`);
3366
+ sections.push(parts.join("\n"));
3367
+ }
3368
+ sections.push(`--- fan-out summary: ${okCount} ok / ${failCount} failed across ${allResults.length} servers (parallelism ${parallelism}) ---`);
3369
+ return { content: [{ type: "text", text: sections.join("\n\n") }] };
3027
3370
  }
3028
3371
  // ----- SFTP -----
3029
3372
  case "sftp-list": {
3030
- const { conn, proxy } = await getServerConnection(String(a.serverId));
3031
- const listing = await sftpReaddir(conn, String(a.path || "/"), proxy, {
3032
- recursive: a.recursive === true,
3033
- maxDepth: typeof a.maxDepth === "number" ? a.maxDepth : void 0,
3034
- pattern: typeof a.pattern === "string" ? a.pattern : void 0,
3035
- maxResults: typeof a.maxResults === "number" ? a.maxResults : void 0
3373
+ const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
3374
+ const recursive = a.recursive === true;
3375
+ const maxResults = Math.max(1, Math.min(5e4, Number(a.maxResults) || 5e3));
3376
+ const pattern = typeof a.pattern === "string" ? a.pattern : "";
3377
+ const summary = a.summary === true;
3378
+ const sortBy = a.sortBy === "size" || a.sortBy === "mtime" || a.sortBy === "name" ? a.sortBy : "name";
3379
+ const reverse = a.reverse === true;
3380
+ let entries;
3381
+ let truncated = false;
3382
+ let warning = "";
3383
+ if (bucket) {
3384
+ const prefix = r2Key(String(a.path || ""));
3385
+ const matcher = pattern ? globToRegExp(pattern) : null;
3386
+ const r2Entries = await r2List(bucket, prefix, { recursive, maxResults });
3387
+ const filtered = matcher ? r2Entries.filter((e) => matcher.test(e.key.split("/").pop() || e.key)) : r2Entries;
3388
+ entries = filtered.map((e) => {
3389
+ const ms = e.mtime ? Date.parse(e.mtime) : 0;
3390
+ return {
3391
+ kind: e.isPrefix ? "d" : "-",
3392
+ size: e.size,
3393
+ mtime: e.mtime,
3394
+ mtimeMs: Number.isFinite(ms) ? ms : 0,
3395
+ path: e.key
3396
+ };
3397
+ });
3398
+ } else {
3399
+ const { conn, proxy } = await getServerConnection(String(a.serverId));
3400
+ const result = await sftpReaddir(conn, String(a.path || "/"), proxy, {
3401
+ recursive,
3402
+ maxDepth: typeof a.maxDepth === "number" ? a.maxDepth : void 0,
3403
+ pattern: pattern || void 0,
3404
+ maxResults
3405
+ });
3406
+ if (result.error && result.entries.length === 0) {
3407
+ return { content: [{ type: "text", text: `Error: ${result.error}` }] };
3408
+ }
3409
+ entries = result.entries;
3410
+ truncated = result.truncated;
3411
+ if (result.error) warning = result.error;
3412
+ }
3413
+ if (summary) {
3414
+ const files = entries.filter((e) => e.kind === "-");
3415
+ const dirs = entries.filter((e) => e.kind === "d");
3416
+ const totalSize = files.reduce((acc, e) => acc + (e.size || 0), 0);
3417
+ const lines2 = [
3418
+ `Files: ${files.length}`,
3419
+ `Directories: ${dirs.length}`,
3420
+ `Total size: ${totalSize} bytes (${(totalSize / (1024 * 1024)).toFixed(2)} MB)`
3421
+ ];
3422
+ if (truncated) lines2.push(`(truncated at ${maxResults}; counts may be partial)`);
3423
+ if (warning) lines2.push(`(warnings: ${warning})`);
3424
+ return { content: [{ type: "text", text: lines2.join("\n") }] };
3425
+ }
3426
+ entries.sort((x, y) => {
3427
+ if (x.kind !== y.kind) return x.kind === "d" ? -1 : 1;
3428
+ let cmp = 0;
3429
+ if (sortBy === "size") cmp = x.size - y.size;
3430
+ else if (sortBy === "mtime") cmp = x.mtimeMs - y.mtimeMs;
3431
+ else cmp = x.path.localeCompare(y.path);
3432
+ return reverse ? -cmp : cmp;
3036
3433
  });
3037
- return { content: [{ type: "text", text: listing }] };
3434
+ if (entries.length === 0) return { content: [{ type: "text", text: "No entries" }] };
3435
+ const lines = entries.map((e) => {
3436
+ const sizeCol = e.kind === "d" && bucket ? "(prefix)" : String(e.size);
3437
+ return `${e.kind} ${sizeCol.padStart(10)} ${(e.mtime || "").padEnd(24)} ${e.path}`;
3438
+ });
3439
+ if (truncated) lines.push(`... (truncated at ${maxResults} entries; raise maxResults or narrow path/pattern)`);
3440
+ if (warning) lines.push(`! ${warning}`);
3441
+ return { content: [{ type: "text", text: lines.join("\n") }] };
3038
3442
  }
3039
3443
  case "sftp-read": {
3444
+ const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
3445
+ const offset = a.offset !== void 0 ? Math.max(0, Number(a.offset)) : void 0;
3446
+ const length = a.length !== void 0 ? Math.max(0, Number(a.length)) : void 0;
3447
+ const hasRange = offset !== void 0 || length !== void 0;
3448
+ if (bucket) {
3449
+ const key = r2Key(String(a.path));
3450
+ if (!key) return { content: [{ type: "text", text: "Error: path is required" }] };
3451
+ try {
3452
+ if (hasRange) {
3453
+ const content3 = await r2GetObjectRange(bucket, key, { offset: offset ?? 0, length });
3454
+ return { content: [{ type: "text", text: content3 }] };
3455
+ }
3456
+ const content2 = await r2GetObject(bucket, key, 1048576);
3457
+ return { content: [{ type: "text", text: content2 }] };
3458
+ } catch (e) {
3459
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
3460
+ }
3461
+ }
3040
3462
  const { conn, proxy } = await getServerConnection(String(a.serverId));
3041
- const content = await sftpRead(conn, String(a.path), proxy);
3463
+ const content = await sftpRead(conn, String(a.path), proxy, hasRange ? { offset, length } : void 0);
3042
3464
  return { content: [{ type: "text", text: content }] };
3043
3465
  }
3044
3466
  case "sftp-write": {
3467
+ const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
3468
+ let fileMode;
3469
+ if (typeof a.mode === "number" && Number.isFinite(a.mode)) fileMode = a.mode & 4095;
3470
+ else if (typeof a.mode === "string" && a.mode) {
3471
+ const cleaned = a.mode.replace(/^0o?/i, "");
3472
+ const parsed = parseInt(cleaned, 8);
3473
+ if (!Number.isNaN(parsed)) fileMode = parsed & 4095;
3474
+ }
3475
+ let mtimeMs;
3476
+ if (typeof a.mtime === "number" && Number.isFinite(a.mtime)) mtimeMs = a.mtime;
3477
+ else if (typeof a.mtime === "string" && a.mtime) {
3478
+ const parsed = Date.parse(a.mtime);
3479
+ if (!Number.isNaN(parsed)) mtimeMs = parsed;
3480
+ }
3481
+ if (bucket) {
3482
+ const key = r2Key(String(a.path));
3483
+ if (!key) return { content: [{ type: "text", text: "Error: path is required" }] };
3484
+ if (fileMode !== void 0 || mtimeMs !== void 0) {
3485
+ }
3486
+ try {
3487
+ if (typeof a.sourcePath === "string" && a.sourcePath.length > 0) {
3488
+ const local = a.sourcePath;
3489
+ if (!isAbsolute(local)) return { content: [{ type: "text", text: `Error: sourcePath must be an absolute path: ${local}` }] };
3490
+ if (!existsSync(local)) return { content: [{ type: "text", text: `Error: sourcePath does not exist: ${local}` }] };
3491
+ const st = statSync(local);
3492
+ if (!st.isFile()) return { content: [{ type: "text", text: `Error: sourcePath is not a regular file: ${local}` }] };
3493
+ const startedAt = Date.now();
3494
+ if (st.size > R2_MULTIPART_THRESHOLD) {
3495
+ let lastLog = 0;
3496
+ await r2PutObjectMultipart(bucket, key, local, st.size, (uploaded) => {
3497
+ if (uploaded - lastLog >= 500 * 1024 * 1024) {
3498
+ console.error(`[r2-write] ${formatBytes(uploaded)} / ${formatBytes(st.size)} \u2192 r2://${bucket}/${key}`);
3499
+ lastLog = uploaded;
3500
+ }
3501
+ });
3502
+ const elapsed2 = Date.now() - startedAt;
3503
+ const mbps = st.size / (1024 * 1024) / Math.max(1e-3, elapsed2 / 1e3);
3504
+ const noteParts = [];
3505
+ if (fileMode !== void 0) noteParts.push("mode ignored (R2)");
3506
+ if (mtimeMs !== void 0) noteParts.push("mtime ignored (R2)");
3507
+ const note = noteParts.length ? ` [${noteParts.join(", ")}]` : "";
3508
+ return { content: [{ type: "text", text: `Uploaded ${formatBytes(st.size)} from ${local} to r2://${bucket}/${key} via multipart in ${(elapsed2 / 1e3).toFixed(1)}s (${mbps.toFixed(1)} MB/s)${note}` }] };
3509
+ }
3510
+ const stream = createReadStream(local);
3511
+ await r2PutObject(bucket, key, stream, st.size);
3512
+ const elapsed = Date.now() - startedAt;
3513
+ return { content: [{ type: "text", text: `Uploaded ${st.size} bytes from ${local} to r2://${bucket}/${key} in ${elapsed}ms` }] };
3514
+ }
3515
+ const buf = Buffer.from(String(a.content ?? ""), "utf-8");
3516
+ await r2PutObject(bucket, key, buf, buf.length);
3517
+ return { content: [{ type: "text", text: `Wrote ${buf.length} bytes to r2://${bucket}/${key}` }] };
3518
+ } catch (e) {
3519
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
3520
+ }
3521
+ }
3045
3522
  const { conn, proxy } = await getServerConnection(String(a.serverId));
3046
3523
  const input = typeof a.sourcePath === "string" && a.sourcePath.length > 0 ? { sourcePath: a.sourcePath } : { content: String(a.content ?? "") };
3047
- const result = await sftpWrite(conn, String(a.path), input, proxy);
3524
+ const result = await sftpWrite(conn, String(a.path), input, proxy, { fileMode, mtimeMs });
3048
3525
  return { content: [{ type: "text", text: result }] };
3049
3526
  }
3050
3527
  case "sftp-delete": {
3528
+ const bucket = typeof a.bucket === "string" && a.bucket ? a.bucket : "";
3529
+ const recursive = a.recursive === true;
3530
+ if (bucket) {
3531
+ const key = r2Key(String(a.path));
3532
+ if (!key) return { content: [{ type: "text", text: "Error: path is required" }] };
3533
+ try {
3534
+ if (recursive) {
3535
+ const deleted = await r2DeletePrefix(bucket, key);
3536
+ return { content: [{ type: "text", text: `Deleted ${deleted} object(s) under r2://${bucket}/${key}` }] };
3537
+ }
3538
+ await r2DeleteObject(bucket, key);
3539
+ return { content: [{ type: "text", text: `Deleted r2://${bucket}/${key}` }] };
3540
+ } catch (e) {
3541
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
3542
+ }
3543
+ }
3051
3544
  const { conn, proxy } = await getServerConnection(String(a.serverId));
3052
- const result = await sftpDelete(conn, String(a.path), proxy);
3545
+ const result = await sftpDelete(conn, String(a.path), proxy, { recursive });
3053
3546
  return { content: [{ type: "text", text: result }] };
3054
3547
  }
3055
3548
  // ----- Docker -----
3056
3549
  case "docker-list": {
3057
3550
  const format = a.format === "json" ? "json" : "table";
3058
3551
  const composeOnly = a.composeOnly === true;
3059
- const filterArg = composeOnly ? ' --filter "label=com.docker.compose.project"' : "";
3060
- const fmtArg = format === "json" ? ` --format '{{json .}}'` : (
3061
- // Add the compose project as the last column. Docker exposes
3062
- // labels through the `.Label "key"` method (Labels itself is a
3063
- // string slice "k=v,k=v" in docker ps, not a map — `index` fails).
3064
- ` --format 'table {{.Names}} {{.Image}} {{.Status}} {{.Ports}} {{.Label "com.docker.compose.project"}}'`
3065
- );
3066
- const cmd = `docker ps -a${filterArg}${fmtArg}`;
3552
+ const groupByProject = a.groupByProject === true;
3553
+ 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()] : [];
3554
+ const filterArg = composeOnly ? ` --filter "label=com.docker.compose.project"` : "";
3555
+ const cmd = `docker ps -a${filterArg} --format '{{json .}}'`;
3067
3556
  const { conn, proxy } = await getServerConnection(String(a.serverId));
3068
3557
  const result = await sshExec(conn, cmd, proxy);
3069
- return { content: [{ type: "text", text: result.exitCode === 0 ? result.stdout : `Error: ${result.stderr}` }] };
3558
+ if (result.exitCode !== 0) {
3559
+ return { content: [{ type: "text", text: `Error: ${result.stderr || result.stdout}` }] };
3560
+ }
3561
+ const containers = [];
3562
+ for (const line of result.stdout.split("\n")) {
3563
+ const trimmed = line.trim();
3564
+ if (!trimmed) continue;
3565
+ try {
3566
+ containers.push(JSON.parse(trimmed));
3567
+ } catch {
3568
+ }
3569
+ }
3570
+ for (const c of containers) {
3571
+ const m = (c.Labels || "").match(/com\.docker\.compose\.project=([^,]+)/);
3572
+ c.Project = m ? m[1] : "";
3573
+ const status = c.Status || "";
3574
+ if (/\(unhealthy\)/i.test(status)) c.Health = "unhealthy";
3575
+ else if (/\(health: starting\)|\(starting\)/i.test(status)) c.Health = "starting";
3576
+ else if (/\(healthy\)/i.test(status)) c.Health = "healthy";
3577
+ else if (/^Up\b/i.test(status)) c.Health = "no-check";
3578
+ else c.Health = "";
3579
+ }
3580
+ let filtered = containers;
3581
+ if (nameFiltersRaw.length > 0) {
3582
+ const matchers = nameFiltersRaw.map((raw) => {
3583
+ if (raw.includes("*") || raw.includes("?")) return { kind: "glob", re: globToRegExp(raw) };
3584
+ return { kind: "sub", lower: raw.toLowerCase() };
3585
+ });
3586
+ filtered = containers.filter((c) => {
3587
+ const name2 = c.Names || "";
3588
+ const lower = name2.toLowerCase();
3589
+ return matchers.some((m) => m.kind === "glob" ? m.re.test(name2) : lower.includes(m.lower));
3590
+ });
3591
+ }
3592
+ filtered.sort((x, y) => {
3593
+ const px = x.Project || "~";
3594
+ const py = y.Project || "~";
3595
+ const p = px.localeCompare(py);
3596
+ return p !== 0 ? p : (x.Names || "").localeCompare(y.Names || "");
3597
+ });
3598
+ if (format === "json") {
3599
+ const text2 = filtered.map((c) => JSON.stringify(c)).join("\n") || "(no containers)";
3600
+ return { content: [{ type: "text", text: text2 }] };
3601
+ }
3602
+ const fmtRow = (r) => `${(r.Names || "").padEnd(36)} ${(r.Image || "").padEnd(40)} ${(r.Status || "").padEnd(24)} ${(r.Health || "").padEnd(10)} ${(r.Ports || "").padEnd(40)}`;
3603
+ if (groupByProject) {
3604
+ const groups = /* @__PURE__ */ new Map();
3605
+ for (const c of filtered) {
3606
+ const key = c.Project || "(no compose project)";
3607
+ const list = groups.get(key);
3608
+ if (list) list.push(c);
3609
+ else groups.set(key, [c]);
3610
+ }
3611
+ const out = [];
3612
+ for (const [project, rows] of groups) {
3613
+ out.push(`=== ${project} (${rows.length}) ===`);
3614
+ for (const r of rows) out.push(" " + fmtRow(r));
3615
+ out.push("");
3616
+ }
3617
+ return { content: [{ type: "text", text: out.join("\n").trimEnd() || "(no containers)" }] };
3618
+ }
3619
+ const header = `${"NAMES".padEnd(36)} ${"IMAGE".padEnd(40)} ${"STATUS".padEnd(24)} ${"HEALTH".padEnd(10)} ${"PORTS".padEnd(40)} PROJECT`;
3620
+ const body = filtered.map((r) => `${fmtRow(r)} ${r.Project || ""}`);
3621
+ const text = filtered.length === 0 ? "(no containers match)" : [header, ...body].join("\n");
3622
+ return { content: [{ type: "text", text }] };
3070
3623
  }
3071
3624
  case "docker-logs": {
3072
- const container = String(a.containerName).replace(/[^a-zA-Z0-9._-]/g, "");
3073
- const lines = Number(a.lines) || 100;
3625
+ const rawNames = Array.isArray(a.containerName) ? a.containerName.map(String) : [String(a.containerName)];
3626
+ const containers = rawNames.map((n) => n.replace(/[^a-zA-Z0-9._-]/g, "")).filter((n) => n.length > 0);
3627
+ if (containers.length === 0) {
3628
+ return { content: [{ type: "text", text: "Error: no valid containerName provided" }] };
3629
+ }
3630
+ const tail = Number(a.tail) > 0 ? Number(a.tail) : Number(a.lines) > 0 ? Number(a.lines) : 100;
3074
3631
  const sinceRaw = typeof a.since === "string" ? a.since.trim() : "";
3075
3632
  const grepRaw = typeof a.grep === "string" ? a.grep : "";
3076
- let sinceArg = "";
3077
- if (sinceRaw) {
3078
- if (!/^\d+[smhd]$/i.test(sinceRaw) && !/^\d{4}-\d{2}-\d{2}/.test(sinceRaw)) {
3079
- return { content: [{ type: "text", text: 'Error: invalid `since` format (expected e.g. "10m", "2h", or ISO timestamp)' }] };
3080
- }
3081
- sinceArg = ` --since ${posixQuote(sinceRaw)}`;
3633
+ const followSeconds = Math.max(0, Math.min(300, Number(a.followSeconds) || 0));
3634
+ if (sinceRaw && !/^\d+[smhd]$/i.test(sinceRaw) && !/^\d{4}-\d{2}-\d{2}/.test(sinceRaw)) {
3635
+ return { content: [{ type: "text", text: 'Error: invalid `since` format (expected e.g. "10m", "2h", or ISO timestamp)' }] };
3082
3636
  }
3083
- const grepSuffix = grepRaw ? ` | grep -i -E ${posixQuote(grepRaw)}` : "";
3084
- const cmd = `docker logs --tail ${lines}${sinceArg} ${container} 2>&1${grepSuffix}`;
3637
+ const sinceArg = sinceRaw ? ` --since ${posixQuote(sinceRaw)}` : "";
3638
+ const grepSuffix = grepRaw ? ` | grep -i -E ${posixQuote(grepRaw)} --line-buffered` : "";
3639
+ const followTail = a.tail !== void 0 || a.lines !== void 0 ? tail : 0;
3640
+ const tailArg = followSeconds > 0 ? followTail : tail;
3085
3641
  const { conn, proxy } = await getServerConnection(String(a.serverId));
3642
+ if (followSeconds > 0) conn.timeout = (followSeconds + 10) * 1e3;
3643
+ const followFlag = followSeconds > 0 ? " -f" : "";
3644
+ if (containers.length === 1) {
3645
+ const c = containers[0];
3646
+ const inner2 = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
3647
+ const cmd2 = followSeconds > 0 ? `timeout --signal=INT ${followSeconds} sh -c ${posixQuote(inner2)}` : inner2;
3648
+ const result2 = await sshExec(conn, cmd2, proxy);
3649
+ const acceptable2 = followSeconds > 0 ? result2.exitCode === 0 || result2.exitCode === 124 || result2.exitCode === 130 : result2.exitCode === 0 || grepRaw && result2.exitCode === 1;
3650
+ if (!acceptable2) {
3651
+ return { content: [{ type: "text", text: `Error (exit ${result2.exitCode}): ${result2.stderr || result2.stdout}` }] };
3652
+ }
3653
+ return { content: [{ type: "text", text: result2.stdout || "(no log lines matched)" }] };
3654
+ }
3655
+ const subShells = containers.map((c) => {
3656
+ const inner2 = `docker logs${followFlag} --tail ${tailArg}${sinceArg} ${c} 2>&1${grepSuffix}`;
3657
+ return `(${inner2} | sed -u -e ${posixQuote(`s/^/[${c}] /`)})`;
3658
+ });
3659
+ const inner = followSeconds > 0 ? subShells.join(" & ") + " & wait" : subShells.join("; ");
3660
+ const cmd = followSeconds > 0 ? `timeout --signal=INT ${followSeconds} sh -c ${posixQuote(inner)}` : inner;
3086
3661
  const result = await sshExec(conn, cmd, proxy);
3087
- if (result.exitCode !== 0 && !(grepRaw && result.exitCode === 1)) {
3662
+ const acceptable = followSeconds > 0 ? result.exitCode === 0 || result.exitCode === 124 || result.exitCode === 130 || result.exitCode === 143 : result.exitCode === 0 || grepRaw && result.exitCode === 1;
3663
+ if (!acceptable) {
3088
3664
  return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` }] };
3089
3665
  }
3090
3666
  return { content: [{ type: "text", text: result.stdout || "(no log lines matched)" }] };
@@ -3095,14 +3671,20 @@ ${result.stderr}`);
3095
3671
  const command = String(a.command);
3096
3672
  const args2 = Array.isArray(a.args) ? a.args.map(String) : [];
3097
3673
  const stdin = typeof a.stdin === "string" ? a.stdin : void 0;
3674
+ const interactive = a.interactive === true;
3098
3675
  const workdir = typeof a.workdir === "string" && a.workdir ? ["-w", a.workdir] : [];
3099
3676
  const user = typeof a.user === "string" && a.user ? ["-u", a.user] : [];
3100
- const stdinFlag = stdin !== void 0 ? ["-i"] : [];
3101
- const dockerArgs = [...stdinFlag, ...workdir, ...user, container, command, ...args2];
3677
+ const flags = [];
3678
+ if (stdin !== void 0 || interactive) flags.push("-i");
3679
+ if (interactive) flags.push("-t");
3680
+ const dockerArgs = [...flags, ...workdir, ...user, container, command, ...args2];
3102
3681
  const fullCmd = buildPosixCommand("docker", ["exec", ...dockerArgs]);
3103
3682
  const { conn, proxy } = await getServerConnection(String(a.serverId));
3104
3683
  if (a.timeout) conn.timeout = Number(a.timeout);
3105
- const result = await sshExec(conn, fullCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
3684
+ const result = await sshExec(conn, fullCmd, proxy, {
3685
+ stdin,
3686
+ pty: interactive
3687
+ });
3106
3688
  const output = [`Exit code: ${result.exitCode}`];
3107
3689
  if (result.stdout) output.push(`--- stdout ---
3108
3690
  ${result.stdout}`);
@@ -3390,49 +3972,6 @@ echo -e "$R"
3390
3972
 
3391
3973
  ${lines.join("\n")}` }] };
3392
3974
  }
3393
- case "domain-get": {
3394
- const domain = String(a.domain);
3395
- if (!domain) throw new Error("domain is required");
3396
- const res = await mijnhostFetch(`/domains/${encodeURIComponent(domain)}`);
3397
- const d = res.data;
3398
- const sections = [
3399
- `=== ${d.domain} ===`,
3400
- `Status: ${d.status}`,
3401
- `Renewal: ${d.renewal_date}`,
3402
- `Locked: ${d.is_locked ? "Yes" : "No"} (lockable: ${d.is_lockable ? "Yes" : "No"})`,
3403
- `Managed DNS: ${d.managed_dns ? "Yes" : "No"}`,
3404
- `DNSSEC: ${d.dnssec_enabled ? "Enabled" : "Disabled"}`,
3405
- `Nameservers: ${d.nameservers.join(", ")}`,
3406
- "",
3407
- "--- Contact Handles ---",
3408
- `Owner: ${d.handles?.owner?.name ?? "-"}`,
3409
- `Admin: ${d.handles?.admin?.name ?? "-"}`,
3410
- `Tech: ${d.handles?.tech?.name ?? "-"}`,
3411
- `Reseller: ${d.handles?.reseller?.name ?? "-"}`
3412
- ];
3413
- if (d.messages?.length) {
3414
- sections.push("", "--- Messages ---", ...d.messages);
3415
- }
3416
- return { content: [{ type: "text", text: sections.join("\n") }] };
3417
- }
3418
- case "domain-update-ns": {
3419
- const domain = String(a.domain);
3420
- const nameserver = String(a.nameserver);
3421
- if (!domain || !nameserver) throw new Error("domain and nameserver are required");
3422
- await mijnhostFetch(`/domains/${encodeURIComponent(domain)}`, {
3423
- method: "PUT",
3424
- body: JSON.stringify({ nameserver })
3425
- });
3426
- const verify = await mijnhostFetch(`/domains/${encodeURIComponent(domain)}`);
3427
- const ns = verify.data.nameservers;
3428
- return {
3429
- content: [{
3430
- type: "text",
3431
- text: `Nameservers for ${domain} updated to profile "${nameserver}".
3432
- Current nameservers: ${ns.join(", ")}`
3433
- }]
3434
- };
3435
- }
3436
3975
  case "dns-list": {
3437
3976
  const domain = String(a.domain);
3438
3977
  if (!domain) throw new Error("domain is required");