@mgsoftwarebv/mg-dashboard-mcp 3.6.1 → 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 +726 -187
- package/dist/index.js.map +1 -1
- package/package.json +48 -46
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
2324
|
+
resolve({ entries: [], truncated: false, error: err.message });
|
|
2158
2325
|
return;
|
|
2159
2326
|
}
|
|
2160
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
},
|
|
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
|
|
2621
|
-
inputSchema: {
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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: ["
|
|
2877
|
+
required: ["command"]
|
|
2637
2878
|
}
|
|
2638
2879
|
},
|
|
2639
2880
|
{
|
|
2640
2881
|
name: "sftp-list",
|
|
2641
|
-
description: 'List files
|
|
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
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
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
|
|
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
|
-
|
|
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: ["
|
|
2911
|
+
required: ["path"]
|
|
2665
2912
|
}
|
|
2666
2913
|
},
|
|
2667
2914
|
{
|
|
2668
2915
|
name: "sftp-write",
|
|
2669
|
-
description: "Write a file to
|
|
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
|
-
|
|
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
|
|
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: ["
|
|
2928
|
+
required: ["path"]
|
|
2679
2929
|
}
|
|
2680
2930
|
},
|
|
2681
2931
|
{
|
|
2682
2932
|
name: "sftp-delete",
|
|
2683
|
-
description: "Delete a file or empty directory on
|
|
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
|
-
|
|
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: ["
|
|
2942
|
+
required: ["path"]
|
|
2691
2943
|
}
|
|
2692
2944
|
},
|
|
2693
2945
|
{
|
|
2694
2946
|
name: "docker-list",
|
|
2695
|
-
description: "List Docker containers on a remote server.
|
|
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:
|
|
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: "
|
|
2714
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
3010
|
-
|
|
3011
|
-
const
|
|
3012
|
-
|
|
3013
|
-
|
|
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
|
|
3021
|
-
const
|
|
3022
|
-
|
|
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
|
-
|
|
3337
|
+
if (result.stderr) output.push(`--- stderr ---
|
|
3025
3338
|
${result.stderr}`);
|
|
3026
|
-
|
|
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
|
|
3031
|
-
const
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
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:
|
|
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
|
|
3060
|
-
const
|
|
3061
|
-
|
|
3062
|
-
|
|
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
|
-
|
|
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
|
|
3073
|
-
const
|
|
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
|
-
|
|
3077
|
-
if (sinceRaw) {
|
|
3078
|
-
|
|
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
|
|
3084
|
-
const
|
|
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
|
-
|
|
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
|
|
3101
|
-
|
|
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,
|
|
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");
|