@mgsoftwarebv/mg-dashboard-mcp 3.4.4 → 3.6.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.d.ts +7 -0
- package/dist/index.js +385 -73
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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, readFileSync } from 'fs';
|
|
4
|
-
import { join } from 'path';
|
|
3
|
+
import { existsSync, statSync, readFileSync } from 'fs';
|
|
4
|
+
import { join, isAbsolute } 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';
|
|
@@ -1372,6 +1372,8 @@ var TOOL_MODULE_MAP = {
|
|
|
1372
1372
|
"sftp-delete": "ssh_servers",
|
|
1373
1373
|
"docker-list": "ssh_servers",
|
|
1374
1374
|
"docker-logs": "ssh_servers",
|
|
1375
|
+
"docker-exec": "ssh_servers",
|
|
1376
|
+
"docker-compose": "ssh_servers",
|
|
1375
1377
|
"db-discover": "ssh_servers",
|
|
1376
1378
|
"db-tables": "ssh_servers",
|
|
1377
1379
|
"db-describe": "ssh_servers",
|
|
@@ -1819,6 +1821,28 @@ async function attemptVercelSync(appName, environment, knownStageId) {
|
|
|
1819
1821
|
return `Vercel sync error: ${msg}`;
|
|
1820
1822
|
}
|
|
1821
1823
|
}
|
|
1824
|
+
function posixQuote(arg) {
|
|
1825
|
+
if (arg === "") return "''";
|
|
1826
|
+
if (/^[A-Za-z0-9._\/=:@%+\-]+$/.test(arg)) return arg;
|
|
1827
|
+
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
1828
|
+
}
|
|
1829
|
+
function buildPosixCommand(command, args2) {
|
|
1830
|
+
const program = /^[A-Za-z0-9._\/\- ]+$/.test(command) ? command : posixQuote(command);
|
|
1831
|
+
if (args2.length === 0) return program;
|
|
1832
|
+
return `${program} ${args2.map(posixQuote).join(" ")}`;
|
|
1833
|
+
}
|
|
1834
|
+
function buildPowerShellEncodedCommand(command, args2) {
|
|
1835
|
+
const psSingleQuote = (s) => "'" + s.replace(/'/g, "''") + "'";
|
|
1836
|
+
let psExpr;
|
|
1837
|
+
if (args2.length === 0) {
|
|
1838
|
+
psExpr = command;
|
|
1839
|
+
} else {
|
|
1840
|
+
psExpr = `& ${psSingleQuote(command)} ${args2.map(psSingleQuote).join(" ")}`;
|
|
1841
|
+
}
|
|
1842
|
+
const utf16 = Buffer.from(psExpr, "utf16le");
|
|
1843
|
+
const b64 = utf16.toString("base64");
|
|
1844
|
+
return `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${b64}`;
|
|
1845
|
+
}
|
|
1822
1846
|
var SSH_PROXY_SERVER_ID = "03659d55-e194-400d-b82a-bf6457371ded";
|
|
1823
1847
|
var _proxyConnCache = null;
|
|
1824
1848
|
async function getProxyConnection() {
|
|
@@ -1838,7 +1862,7 @@ async function getProxyConnection() {
|
|
|
1838
1862
|
}
|
|
1839
1863
|
async function getServerConnection(serverId) {
|
|
1840
1864
|
assertServerAccess(serverId);
|
|
1841
|
-
const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted, allowed_ssh_ips").eq("id", serverId).single();
|
|
1865
|
+
const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted, allowed_ssh_ips, os_type").eq("id", serverId).single();
|
|
1842
1866
|
if (error || !data) throw new Error(`Server not found: ${serverId}`);
|
|
1843
1867
|
if (!encryptionKey) throw new Error("ENCRYPTION_KEY required to decrypt server credentials");
|
|
1844
1868
|
const conn = {
|
|
@@ -1851,10 +1875,11 @@ async function getServerConnection(serverId) {
|
|
|
1851
1875
|
};
|
|
1852
1876
|
const needsProxy = data.allowed_ssh_ips !== null && serverId !== SSH_PROXY_SERVER_ID;
|
|
1853
1877
|
const proxy = needsProxy ? await getProxyConnection() : void 0;
|
|
1854
|
-
|
|
1878
|
+
const os = data.os_type === "windows" ? "windows" : "linux";
|
|
1879
|
+
return { conn, proxy, os };
|
|
1855
1880
|
}
|
|
1856
|
-
async function sshExec(opts, command, proxy) {
|
|
1857
|
-
if (proxy) return sshExecViaProxy(proxy, opts, command);
|
|
1881
|
+
async function sshExec(opts, command, proxy, options) {
|
|
1882
|
+
if (proxy) return sshExecViaProxy(proxy, opts, command, options);
|
|
1858
1883
|
return new Promise((resolve) => {
|
|
1859
1884
|
const ssh = new Client();
|
|
1860
1885
|
let stdout = "";
|
|
@@ -1893,6 +1918,9 @@ async function sshExec(opts, command, proxy) {
|
|
|
1893
1918
|
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
1894
1919
|
}
|
|
1895
1920
|
});
|
|
1921
|
+
if (options?.stdin !== void 0) {
|
|
1922
|
+
stream.end(options.stdin);
|
|
1923
|
+
}
|
|
1896
1924
|
});
|
|
1897
1925
|
});
|
|
1898
1926
|
ssh.on("error", (err) => {
|
|
@@ -1913,7 +1941,7 @@ async function sshExec(opts, command, proxy) {
|
|
|
1913
1941
|
});
|
|
1914
1942
|
});
|
|
1915
1943
|
}
|
|
1916
|
-
function sshExecViaProxy(proxyOpts, targetOpts, command) {
|
|
1944
|
+
function sshExecViaProxy(proxyOpts, targetOpts, command, options) {
|
|
1917
1945
|
return new Promise((resolve) => {
|
|
1918
1946
|
const proxyClient = new Client();
|
|
1919
1947
|
let done = false;
|
|
@@ -1967,6 +1995,9 @@ function sshExecViaProxy(proxyOpts, targetOpts, command) {
|
|
|
1967
1995
|
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
1968
1996
|
}
|
|
1969
1997
|
});
|
|
1998
|
+
if (options?.stdin !== void 0) {
|
|
1999
|
+
stream.end(options.stdin);
|
|
2000
|
+
}
|
|
1970
2001
|
});
|
|
1971
2002
|
});
|
|
1972
2003
|
targetClient.on("error", (targetErr) => {
|
|
@@ -2005,7 +2036,8 @@ function sshExecViaProxy(proxyOpts, targetOpts, command) {
|
|
|
2005
2036
|
});
|
|
2006
2037
|
});
|
|
2007
2038
|
}
|
|
2008
|
-
function connectSshClient(opts, proxy, readyTimeout = 6e4) {
|
|
2039
|
+
function connectSshClient(opts, proxy, readyTimeout = 6e4, extraConnect) {
|
|
2040
|
+
const compress = extraConnect?.compress;
|
|
2009
2041
|
if (!proxy) {
|
|
2010
2042
|
return new Promise((resolve, reject) => {
|
|
2011
2043
|
const ssh = new Client();
|
|
@@ -2018,7 +2050,8 @@ function connectSshClient(opts, proxy, readyTimeout = 6e4) {
|
|
|
2018
2050
|
password: opts.password,
|
|
2019
2051
|
privateKey: opts.privateKey,
|
|
2020
2052
|
passphrase: opts.passphrase,
|
|
2021
|
-
readyTimeout
|
|
2053
|
+
readyTimeout,
|
|
2054
|
+
...compress ? { compress } : {}
|
|
2022
2055
|
});
|
|
2023
2056
|
});
|
|
2024
2057
|
}
|
|
@@ -2052,7 +2085,8 @@ function connectSshClient(opts, proxy, readyTimeout = 6e4) {
|
|
|
2052
2085
|
password: opts.password,
|
|
2053
2086
|
privateKey: opts.privateKey,
|
|
2054
2087
|
passphrase: opts.passphrase,
|
|
2055
|
-
readyTimeout
|
|
2088
|
+
readyTimeout,
|
|
2089
|
+
...compress ? { compress } : {}
|
|
2056
2090
|
});
|
|
2057
2091
|
});
|
|
2058
2092
|
});
|
|
@@ -2088,8 +2122,22 @@ function assertWritablePath(path) {
|
|
|
2088
2122
|
}
|
|
2089
2123
|
}
|
|
2090
2124
|
}
|
|
2091
|
-
|
|
2092
|
-
|
|
2125
|
+
function globToRegExp(pattern) {
|
|
2126
|
+
let re = "";
|
|
2127
|
+
for (const c of pattern) {
|
|
2128
|
+
if (c === "*") re += ".*";
|
|
2129
|
+
else if (c === "?") re += ".";
|
|
2130
|
+
else if (/[.+^${}()|[\]\\]/.test(c)) re += "\\" + c;
|
|
2131
|
+
else re += c;
|
|
2132
|
+
}
|
|
2133
|
+
return new RegExp(`^${re}$`);
|
|
2134
|
+
}
|
|
2135
|
+
async function sftpReaddir(opts, dirPath, proxy, options) {
|
|
2136
|
+
const recursive = options?.recursive === true;
|
|
2137
|
+
const maxDepth = Math.max(1, Math.min(20, options?.maxDepth ?? 5));
|
|
2138
|
+
const maxResults = Math.max(1, Math.min(5e4, options?.maxResults ?? 5e3));
|
|
2139
|
+
const matcher = options?.pattern ? globToRegExp(options.pattern) : null;
|
|
2140
|
+
const rootSafe = sanitizePath(dirPath);
|
|
2093
2141
|
let cleanup;
|
|
2094
2142
|
try {
|
|
2095
2143
|
const { client, cleanup: c } = await connectSshClient(opts, proxy, 3e4);
|
|
@@ -2099,7 +2147,7 @@ async function sftpReaddir(opts, dirPath, proxy) {
|
|
|
2099
2147
|
cleanup?.();
|
|
2100
2148
|
resolve("Error: timeout");
|
|
2101
2149
|
cleanup = void 0;
|
|
2102
|
-
},
|
|
2150
|
+
}, 6e4);
|
|
2103
2151
|
client.sftp((err, sftp) => {
|
|
2104
2152
|
if (err) {
|
|
2105
2153
|
clearTimeout(timer);
|
|
@@ -2108,24 +2156,48 @@ async function sftpReaddir(opts, dirPath, proxy) {
|
|
|
2108
2156
|
resolve(`Error: ${err.message}`);
|
|
2109
2157
|
return;
|
|
2110
2158
|
}
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
const
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2159
|
+
const lines = [];
|
|
2160
|
+
let truncated = false;
|
|
2161
|
+
const readOne = (path, depth) => new Promise((resolveOne) => {
|
|
2162
|
+
sftp.readdir(path, (err2, list) => {
|
|
2163
|
+
if (err2) {
|
|
2164
|
+
lines.push(`! error reading ${path}: ${err2.message}`);
|
|
2165
|
+
return resolveOne();
|
|
2166
|
+
}
|
|
2167
|
+
const subdirs = [];
|
|
2168
|
+
for (const item of list) {
|
|
2169
|
+
if (lines.length >= maxResults) {
|
|
2170
|
+
truncated = true;
|
|
2171
|
+
break;
|
|
2172
|
+
}
|
|
2173
|
+
const mode = item.attrs.mode || 0;
|
|
2174
|
+
const isDir = (mode & 61440) === 16384;
|
|
2175
|
+
const size = item.attrs.size || 0;
|
|
2176
|
+
const mtime = item.attrs.mtime ? new Date(item.attrs.mtime * 1e3).toISOString() : "";
|
|
2177
|
+
const fullPath = path === "/" ? `/${item.filename}` : `${path}/${item.filename}`;
|
|
2178
|
+
const include = !matcher || matcher.test(item.filename);
|
|
2179
|
+
if (include) {
|
|
2180
|
+
const display = recursive ? fullPath : item.filename;
|
|
2181
|
+
lines.push(`${isDir ? "d" : "-"} ${String(size).padStart(10)} ${mtime} ${display}`);
|
|
2182
|
+
}
|
|
2183
|
+
if (isDir && recursive && depth < maxDepth) subdirs.push(fullPath);
|
|
2184
|
+
}
|
|
2185
|
+
if (truncated || subdirs.length === 0) return resolveOne();
|
|
2186
|
+
(async () => {
|
|
2187
|
+
for (const sub of subdirs) {
|
|
2188
|
+
if (truncated) break;
|
|
2189
|
+
await readOne(sub, depth + 1);
|
|
2190
|
+
}
|
|
2191
|
+
resolveOne();
|
|
2192
|
+
})();
|
|
2125
2193
|
});
|
|
2194
|
+
});
|
|
2195
|
+
readOne(rootSafe, 1).then(() => {
|
|
2196
|
+
clearTimeout(timer);
|
|
2126
2197
|
cleanup?.();
|
|
2127
2198
|
cleanup = void 0;
|
|
2128
|
-
|
|
2199
|
+
if (truncated) lines.push(`... (truncated at ${maxResults} entries; raise maxResults or narrow path/pattern)`);
|
|
2200
|
+
resolve(lines.length ? lines.join("\n") : "No entries");
|
|
2129
2201
|
});
|
|
2130
2202
|
});
|
|
2131
2203
|
});
|
|
@@ -2192,46 +2264,129 @@ async function sftpRead(opts, filePath, proxy) {
|
|
|
2192
2264
|
return `Error: ${e.message}`;
|
|
2193
2265
|
}
|
|
2194
2266
|
}
|
|
2195
|
-
|
|
2267
|
+
var SFTP_FASTPUT_CONCURRENCY = 64;
|
|
2268
|
+
var SFTP_FASTPUT_CHUNK_SIZE = 65536;
|
|
2269
|
+
var SFTP_IDLE_TIMEOUT_MS = 12e4;
|
|
2270
|
+
var SFTP_INLINE_TIMEOUT_MS = 6e4;
|
|
2271
|
+
var SFTP_PROGRESS_LOG_BYTES = 50 * 1024 * 1024;
|
|
2272
|
+
function formatBytes(bytes) {
|
|
2273
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
2274
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2275
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
2276
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
2277
|
+
}
|
|
2278
|
+
async function sftpWrite(opts, filePath, input, proxy) {
|
|
2196
2279
|
const safe = sanitizePath(filePath);
|
|
2197
2280
|
assertWritablePath(safe);
|
|
2281
|
+
let mode;
|
|
2282
|
+
let inlineBuffer;
|
|
2283
|
+
let localPath;
|
|
2284
|
+
let expectedBytes = 0;
|
|
2285
|
+
if ("content" in input && typeof input.content === "string") {
|
|
2286
|
+
mode = "content";
|
|
2287
|
+
inlineBuffer = Buffer.from(input.content, "utf-8");
|
|
2288
|
+
expectedBytes = inlineBuffer.length;
|
|
2289
|
+
} else if ("sourcePath" in input && typeof input.sourcePath === "string") {
|
|
2290
|
+
mode = "sourcePath";
|
|
2291
|
+
localPath = input.sourcePath;
|
|
2292
|
+
if (!isAbsolute(localPath)) return `Error: sourcePath must be an absolute path: ${localPath}`;
|
|
2293
|
+
if (!existsSync(localPath)) return `Error: sourcePath does not exist: ${localPath}`;
|
|
2294
|
+
const st = statSync(localPath);
|
|
2295
|
+
if (!st.isFile()) return `Error: sourcePath is not a regular file: ${localPath}`;
|
|
2296
|
+
expectedBytes = st.size;
|
|
2297
|
+
} else {
|
|
2298
|
+
return "Error: sftp-write requires exactly one of: content (string) or sourcePath (absolute local path)";
|
|
2299
|
+
}
|
|
2300
|
+
const compress = mode === "content";
|
|
2301
|
+
const startedAt = Date.now();
|
|
2198
2302
|
let cleanup;
|
|
2199
2303
|
try {
|
|
2200
|
-
const { client, cleanup: c } = await connectSshClient(opts, proxy, 6e4);
|
|
2304
|
+
const { client, cleanup: c } = await connectSshClient(opts, proxy, 6e4, { compress });
|
|
2201
2305
|
cleanup = c;
|
|
2202
2306
|
return await new Promise((resolve) => {
|
|
2203
|
-
|
|
2307
|
+
let resolved = false;
|
|
2308
|
+
let bytesWritten = 0;
|
|
2309
|
+
const finish = (msg) => {
|
|
2310
|
+
if (resolved) return;
|
|
2311
|
+
resolved = true;
|
|
2312
|
+
watchdog?.cancel();
|
|
2313
|
+
wallTimer && clearTimeout(wallTimer);
|
|
2204
2314
|
cleanup?.();
|
|
2205
|
-
resolve("Error: timeout");
|
|
2206
2315
|
cleanup = void 0;
|
|
2207
|
-
|
|
2316
|
+
resolve(msg);
|
|
2317
|
+
};
|
|
2318
|
+
let watchdog;
|
|
2319
|
+
let wallTimer;
|
|
2320
|
+
if (mode === "sourcePath") {
|
|
2321
|
+
const armWatchdog = () => {
|
|
2322
|
+
let timer = setTimeout(
|
|
2323
|
+
() => finish(`Error: idle timeout (no SFTP progress for ${SFTP_IDLE_TIMEOUT_MS / 1e3}s, wrote ${formatBytes(bytesWritten)} of ${formatBytes(expectedBytes)})`),
|
|
2324
|
+
SFTP_IDLE_TIMEOUT_MS
|
|
2325
|
+
);
|
|
2326
|
+
return {
|
|
2327
|
+
reset: () => {
|
|
2328
|
+
clearTimeout(timer);
|
|
2329
|
+
timer = setTimeout(
|
|
2330
|
+
() => finish(`Error: idle timeout (no SFTP progress for ${SFTP_IDLE_TIMEOUT_MS / 1e3}s, wrote ${formatBytes(bytesWritten)} of ${formatBytes(expectedBytes)})`),
|
|
2331
|
+
SFTP_IDLE_TIMEOUT_MS
|
|
2332
|
+
);
|
|
2333
|
+
},
|
|
2334
|
+
cancel: () => clearTimeout(timer)
|
|
2335
|
+
};
|
|
2336
|
+
};
|
|
2337
|
+
watchdog = armWatchdog();
|
|
2338
|
+
} else {
|
|
2339
|
+
wallTimer = setTimeout(
|
|
2340
|
+
() => finish(`Error: timeout after ${SFTP_INLINE_TIMEOUT_MS / 1e3}s`),
|
|
2341
|
+
SFTP_INLINE_TIMEOUT_MS
|
|
2342
|
+
);
|
|
2343
|
+
}
|
|
2208
2344
|
client.sftp((err, sftp) => {
|
|
2209
|
-
if (err) {
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2345
|
+
if (err) return finish(`Error: ${err.message}`);
|
|
2346
|
+
if (mode === "content") {
|
|
2347
|
+
const ws = sftp.createWriteStream(safe, { mode: 420 });
|
|
2348
|
+
ws.on("error", (e) => finish(`Error: ${e.message}`));
|
|
2349
|
+
ws.on("close", () => {
|
|
2350
|
+
const elapsed = Date.now() - startedAt;
|
|
2351
|
+
finish(`Written ${expectedBytes} bytes to ${safe} in ${elapsed}ms`);
|
|
2352
|
+
});
|
|
2353
|
+
ws.end(inlineBuffer);
|
|
2214
2354
|
return;
|
|
2215
2355
|
}
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2356
|
+
let nextProgressLog = SFTP_PROGRESS_LOG_BYTES;
|
|
2357
|
+
sftp.fastPut(
|
|
2358
|
+
localPath,
|
|
2359
|
+
safe,
|
|
2360
|
+
{
|
|
2361
|
+
concurrency: SFTP_FASTPUT_CONCURRENCY,
|
|
2362
|
+
chunkSize: SFTP_FASTPUT_CHUNK_SIZE,
|
|
2363
|
+
mode: 420,
|
|
2364
|
+
fileSize: expectedBytes,
|
|
2365
|
+
step: (transferred) => {
|
|
2366
|
+
bytesWritten = transferred;
|
|
2367
|
+
watchdog?.reset();
|
|
2368
|
+
if (transferred >= nextProgressLog) {
|
|
2369
|
+
console.error(
|
|
2370
|
+
`[sftp-write] ${formatBytes(transferred)} / ${formatBytes(expectedBytes)} \u2192 ${safe}`
|
|
2371
|
+
);
|
|
2372
|
+
nextProgressLog += SFTP_PROGRESS_LOG_BYTES;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
},
|
|
2376
|
+
(fpErr) => {
|
|
2377
|
+
if (fpErr) return finish(`Error: ${fpErr.message}`);
|
|
2378
|
+
const elapsed = Date.now() - startedAt;
|
|
2379
|
+
const mbps = expectedBytes / (1024 * 1024) / Math.max(1e-3, elapsed / 1e3);
|
|
2380
|
+
finish(
|
|
2381
|
+
`Uploaded ${formatBytes(expectedBytes)} from ${localPath} to ${safe} in ${(elapsed / 1e3).toFixed(1)}s (${mbps.toFixed(1)} MB/s)`
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
);
|
|
2230
2385
|
});
|
|
2231
2386
|
});
|
|
2232
2387
|
} catch (e) {
|
|
2233
2388
|
cleanup?.();
|
|
2234
|
-
return `Error: ${e.message}`;
|
|
2389
|
+
return `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
2235
2390
|
}
|
|
2236
2391
|
}
|
|
2237
2392
|
async function sftpDelete(opts, filePath, proxy) {
|
|
@@ -2466,12 +2621,15 @@ var TOOLS = [
|
|
|
2466
2621
|
},
|
|
2467
2622
|
{
|
|
2468
2623
|
name: "ssh-execute",
|
|
2469
|
-
description:
|
|
2624
|
+
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.',
|
|
2470
2625
|
inputSchema: {
|
|
2471
2626
|
type: "object",
|
|
2472
2627
|
properties: {
|
|
2473
2628
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
2474
|
-
command: { type: "string", description:
|
|
2629
|
+
command: { type: "string", description: 'Program/command to run (e.g. "mysql", "Get-Service", "df"). Required.' },
|
|
2630
|
+
args: { type: "array", items: { type: "string" }, description: "Optional argv list. When provided, each entry is safely quoted/encoded for the target OS." },
|
|
2631
|
+
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)." },
|
|
2632
|
+
shell: { type: "string", enum: ["auto", "bash", "powershell"], description: `Override shell selection (default "auto" uses the server's os_type).` },
|
|
2475
2633
|
timeout: { type: "number", description: "Timeout in milliseconds (default: 60000)" }
|
|
2476
2634
|
},
|
|
2477
2635
|
required: ["serverId", "command"]
|
|
@@ -2479,12 +2637,16 @@ var TOOLS = [
|
|
|
2479
2637
|
},
|
|
2480
2638
|
{
|
|
2481
2639
|
name: "sftp-list",
|
|
2482
|
-
description:
|
|
2640
|
+
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 ..."`.',
|
|
2483
2641
|
inputSchema: {
|
|
2484
2642
|
type: "object",
|
|
2485
2643
|
properties: {
|
|
2486
2644
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
2487
|
-
path: { type: "string", description: "Directory path to list (default: /)" }
|
|
2645
|
+
path: { type: "string", description: "Directory path to list (default: /)" },
|
|
2646
|
+
recursive: { type: "boolean", description: "Walk into subdirectories (default false)" },
|
|
2647
|
+
maxDepth: { type: "number", description: "Maximum recursion depth (1-20, default 5)" },
|
|
2648
|
+
pattern: { type: "string", description: 'Glob pattern to filter filenames (e.g. "*.conf", "wp-*.php"). Matches basename only.' },
|
|
2649
|
+
maxResults: { type: "number", description: "Cap total entries returned (default 5000, max 50000)" }
|
|
2488
2650
|
},
|
|
2489
2651
|
required: ["serverId"]
|
|
2490
2652
|
}
|
|
@@ -2503,15 +2665,16 @@ var TOOLS = [
|
|
|
2503
2665
|
},
|
|
2504
2666
|
{
|
|
2505
2667
|
name: "sftp-write",
|
|
2506
|
-
description: "Write
|
|
2668
|
+
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.",
|
|
2507
2669
|
inputSchema: {
|
|
2508
2670
|
type: "object",
|
|
2509
2671
|
properties: {
|
|
2510
2672
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
2511
|
-
path: { type: "string", description: "
|
|
2512
|
-
content: { type: "string", description: "
|
|
2673
|
+
path: { type: "string", description: "Remote file path to write" },
|
|
2674
|
+
content: { type: "string", description: "Inline UTF-8 file content (mutually exclusive with sourcePath)" },
|
|
2675
|
+
sourcePath: { type: "string", description: "Absolute local file path to upload via fastPut (mutually exclusive with content). Use for files >1 MB or any binary." }
|
|
2513
2676
|
},
|
|
2514
|
-
required: ["serverId", "path"
|
|
2677
|
+
required: ["serverId", "path"]
|
|
2515
2678
|
}
|
|
2516
2679
|
},
|
|
2517
2680
|
{
|
|
@@ -2528,28 +2691,66 @@ var TOOLS = [
|
|
|
2528
2691
|
},
|
|
2529
2692
|
{
|
|
2530
2693
|
name: "docker-list",
|
|
2531
|
-
description: "List
|
|
2694
|
+
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.",
|
|
2532
2695
|
inputSchema: {
|
|
2533
2696
|
type: "object",
|
|
2534
2697
|
properties: {
|
|
2535
|
-
serverId: { type: "string", description: "UUID of the SSH server" }
|
|
2698
|
+
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
2699
|
+
format: { type: "string", enum: ["table", "json"], description: "Output format: human table (default) or NDJSON (one JSON object per line, includes labels)." },
|
|
2700
|
+
composeOnly: { type: "boolean", description: "Only show containers that have a docker-compose project label." }
|
|
2536
2701
|
},
|
|
2537
2702
|
required: ["serverId"]
|
|
2538
2703
|
}
|
|
2539
2704
|
},
|
|
2540
2705
|
{
|
|
2541
2706
|
name: "docker-logs",
|
|
2542
|
-
description: "Get
|
|
2707
|
+
description: "Get logs from a Docker container. Supports time-window (`since`) and server-side `grep` to keep responses small. Always merges stderr into stdout.",
|
|
2543
2708
|
inputSchema: {
|
|
2544
2709
|
type: "object",
|
|
2545
2710
|
properties: {
|
|
2546
2711
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
2547
2712
|
containerName: { type: "string", description: "Container name or ID" },
|
|
2548
|
-
lines: { type: "number", description: "Number of log lines to retrieve (default: 100)" }
|
|
2713
|
+
lines: { type: "number", description: "Number of log lines to retrieve (default: 100)" },
|
|
2714
|
+
since: { type: "string", description: 'Time window, e.g. "10m", "2h", "24h", or an absolute "2026-05-09T10:00:00".' },
|
|
2715
|
+
grep: { type: "string", description: "Case-insensitive regex/literal filter applied server-side (saves tokens for noisy containers)." }
|
|
2549
2716
|
},
|
|
2550
2717
|
required: ["serverId", "containerName"]
|
|
2551
2718
|
}
|
|
2552
2719
|
},
|
|
2720
|
+
{
|
|
2721
|
+
name: "docker-exec",
|
|
2722
|
+
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 ..."`.',
|
|
2723
|
+
inputSchema: {
|
|
2724
|
+
type: "object",
|
|
2725
|
+
properties: {
|
|
2726
|
+
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
2727
|
+
container: { type: "string", description: "Container name or ID" },
|
|
2728
|
+
command: { type: "string", description: 'Program to run inside the container (e.g. "psql", "wp", "node").' },
|
|
2729
|
+
args: { type: "array", items: { type: "string" }, description: "Argument list, each safely quoted." },
|
|
2730
|
+
stdin: { type: "string", description: "Optional data piped to the container process stdin." },
|
|
2731
|
+
workdir: { type: "string", description: "Working directory inside the container (-w)." },
|
|
2732
|
+
user: { type: "string", description: "User to run as inside the container (-u)." },
|
|
2733
|
+
timeout: { type: "number", description: "Timeout in milliseconds (default: 60000)" }
|
|
2734
|
+
},
|
|
2735
|
+
required: ["serverId", "container", "command"]
|
|
2736
|
+
}
|
|
2737
|
+
},
|
|
2738
|
+
{
|
|
2739
|
+
name: "docker-compose",
|
|
2740
|
+
description: 'Run a docker-compose action against a project on a remote server. Replaces the common `ssh-execute "cd /opt/x && docker compose ..."` pattern. Action enum keeps the surface tiny.',
|
|
2741
|
+
inputSchema: {
|
|
2742
|
+
type: "object",
|
|
2743
|
+
properties: {
|
|
2744
|
+
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
2745
|
+
projectPath: { type: "string", description: "Absolute path to the directory containing docker-compose.yml." },
|
|
2746
|
+
action: { type: "string", enum: ["up", "down", "restart", "logs", "ps", "pull", "build"], description: "Compose action." },
|
|
2747
|
+
service: { type: "string", description: 'Optional service name to scope the action to (e.g. "studio"). Omit to act on the whole project.' },
|
|
2748
|
+
tail: { type: "number", description: "For `logs`: number of lines (default 200)." },
|
|
2749
|
+
timeout: { type: "number", description: "Timeout in milliseconds (default: 120000 \u2014 compose ops can be slow)." }
|
|
2750
|
+
},
|
|
2751
|
+
required: ["serverId", "projectPath", "action"]
|
|
2752
|
+
}
|
|
2753
|
+
},
|
|
2553
2754
|
{
|
|
2554
2755
|
name: "db-discover",
|
|
2555
2756
|
description: "Scan /var/www on a server for web applications (WordPress, PrestaShop, Laravel, .env) and list their database credentials. Use this first to find available sites before running other db-* tools.",
|
|
@@ -2801,10 +3002,22 @@ async function executeToolCall(name, a, _serverId) {
|
|
|
2801
3002
|
case "ssh-execute": {
|
|
2802
3003
|
const command = String(a.command);
|
|
2803
3004
|
assertSafeCommand(command);
|
|
2804
|
-
const
|
|
3005
|
+
const args2 = Array.isArray(a.args) ? a.args.map(String) : void 0;
|
|
3006
|
+
const stdin = typeof a.stdin === "string" ? a.stdin : void 0;
|
|
3007
|
+
const shellOverride = typeof a.shell === "string" ? a.shell : "auto";
|
|
3008
|
+
const { conn, proxy, os } = await getServerConnection(String(a.serverId));
|
|
2805
3009
|
if (a.timeout) conn.timeout = Number(a.timeout);
|
|
2806
|
-
const
|
|
2807
|
-
|
|
3010
|
+
const shell = shellOverride === "auto" ? os === "windows" ? "powershell" : "bash" : shellOverride;
|
|
3011
|
+
let finalCmd;
|
|
3012
|
+
if (args2 && args2.length > 0) {
|
|
3013
|
+
finalCmd = shell === "powershell" ? buildPowerShellEncodedCommand(command, args2) : buildPosixCommand(command, args2);
|
|
3014
|
+
} else if (shell === "powershell" && !/^powershell\b/i.test(command.trim())) {
|
|
3015
|
+
finalCmd = buildPowerShellEncodedCommand(command, []);
|
|
3016
|
+
} else {
|
|
3017
|
+
finalCmd = command;
|
|
3018
|
+
}
|
|
3019
|
+
const result = await sshExec(conn, finalCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
|
|
3020
|
+
const output = [`Exit code: ${result.exitCode} (os: ${os}, shell: ${shell})`];
|
|
2808
3021
|
if (result.stdout) output.push(`--- stdout ---
|
|
2809
3022
|
${result.stdout}`);
|
|
2810
3023
|
if (result.stderr) output.push(`--- stderr ---
|
|
@@ -2814,7 +3027,12 @@ ${result.stderr}`);
|
|
|
2814
3027
|
// ----- SFTP -----
|
|
2815
3028
|
case "sftp-list": {
|
|
2816
3029
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
2817
|
-
const listing = await sftpReaddir(conn, String(a.path || "/"), proxy
|
|
3030
|
+
const listing = await sftpReaddir(conn, String(a.path || "/"), proxy, {
|
|
3031
|
+
recursive: a.recursive === true,
|
|
3032
|
+
maxDepth: typeof a.maxDepth === "number" ? a.maxDepth : void 0,
|
|
3033
|
+
pattern: typeof a.pattern === "string" ? a.pattern : void 0,
|
|
3034
|
+
maxResults: typeof a.maxResults === "number" ? a.maxResults : void 0
|
|
3035
|
+
});
|
|
2818
3036
|
return { content: [{ type: "text", text: listing }] };
|
|
2819
3037
|
}
|
|
2820
3038
|
case "sftp-read": {
|
|
@@ -2824,7 +3042,8 @@ ${result.stderr}`);
|
|
|
2824
3042
|
}
|
|
2825
3043
|
case "sftp-write": {
|
|
2826
3044
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
2827
|
-
const
|
|
3045
|
+
const input = typeof a.sourcePath === "string" && a.sourcePath.length > 0 ? { sourcePath: a.sourcePath } : { content: String(a.content ?? "") };
|
|
3046
|
+
const result = await sftpWrite(conn, String(a.path), input, proxy);
|
|
2828
3047
|
return { content: [{ type: "text", text: result }] };
|
|
2829
3048
|
}
|
|
2830
3049
|
case "sftp-delete": {
|
|
@@ -2834,16 +3053,109 @@ ${result.stderr}`);
|
|
|
2834
3053
|
}
|
|
2835
3054
|
// ----- Docker -----
|
|
2836
3055
|
case "docker-list": {
|
|
3056
|
+
const format = a.format === "json" ? "json" : "table";
|
|
3057
|
+
const composeOnly = a.composeOnly === true;
|
|
3058
|
+
const filterArg = composeOnly ? ' --filter "label=com.docker.compose.project"' : "";
|
|
3059
|
+
const fmtArg = format === "json" ? ` --format '{{json .}}'` : (
|
|
3060
|
+
// Add the compose project as the last column. Docker's --format
|
|
3061
|
+
// template language uses {{ index .Labels "key" }} for label lookup.
|
|
3062
|
+
` --format 'table {{.Names}} {{.Image}} {{.Status}} {{.Ports}} {{ index .Labels "com.docker.compose.project" }}'`
|
|
3063
|
+
);
|
|
3064
|
+
const cmd = `docker ps -a${filterArg}${fmtArg}`;
|
|
2837
3065
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
2838
|
-
const result = await sshExec(conn,
|
|
3066
|
+
const result = await sshExec(conn, cmd, proxy);
|
|
2839
3067
|
return { content: [{ type: "text", text: result.exitCode === 0 ? result.stdout : `Error: ${result.stderr}` }] };
|
|
2840
3068
|
}
|
|
2841
3069
|
case "docker-logs": {
|
|
2842
3070
|
const container = String(a.containerName).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
2843
3071
|
const lines = Number(a.lines) || 100;
|
|
3072
|
+
const sinceRaw = typeof a.since === "string" ? a.since.trim() : "";
|
|
3073
|
+
const grepRaw = typeof a.grep === "string" ? a.grep : "";
|
|
3074
|
+
let sinceArg = "";
|
|
3075
|
+
if (sinceRaw) {
|
|
3076
|
+
if (!/^\d+[smhd]$/i.test(sinceRaw) && !/^\d{4}-\d{2}-\d{2}/.test(sinceRaw)) {
|
|
3077
|
+
return { content: [{ type: "text", text: 'Error: invalid `since` format (expected e.g. "10m", "2h", or ISO timestamp)' }] };
|
|
3078
|
+
}
|
|
3079
|
+
sinceArg = ` --since ${posixQuote(sinceRaw)}`;
|
|
3080
|
+
}
|
|
3081
|
+
const grepSuffix = grepRaw ? ` | grep -i -E ${posixQuote(grepRaw)}` : "";
|
|
3082
|
+
const cmd = `docker logs --tail ${lines}${sinceArg} ${container} 2>&1${grepSuffix}`;
|
|
2844
3083
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
2845
|
-
const result = await sshExec(conn,
|
|
2846
|
-
|
|
3084
|
+
const result = await sshExec(conn, cmd, proxy);
|
|
3085
|
+
if (result.exitCode !== 0 && !(grepRaw && result.exitCode === 1)) {
|
|
3086
|
+
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` }] };
|
|
3087
|
+
}
|
|
3088
|
+
return { content: [{ type: "text", text: result.stdout || "(no log lines matched)" }] };
|
|
3089
|
+
}
|
|
3090
|
+
case "docker-exec": {
|
|
3091
|
+
const container = String(a.container).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
3092
|
+
if (!container) return { content: [{ type: "text", text: "Error: invalid container name" }] };
|
|
3093
|
+
const command = String(a.command);
|
|
3094
|
+
const args2 = Array.isArray(a.args) ? a.args.map(String) : [];
|
|
3095
|
+
const stdin = typeof a.stdin === "string" ? a.stdin : void 0;
|
|
3096
|
+
const workdir = typeof a.workdir === "string" && a.workdir ? ["-w", a.workdir] : [];
|
|
3097
|
+
const user = typeof a.user === "string" && a.user ? ["-u", a.user] : [];
|
|
3098
|
+
const stdinFlag = stdin !== void 0 ? ["-i"] : [];
|
|
3099
|
+
const dockerArgs = [...stdinFlag, ...workdir, ...user, container, command, ...args2];
|
|
3100
|
+
const fullCmd = buildPosixCommand("docker", ["exec", ...dockerArgs]);
|
|
3101
|
+
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
3102
|
+
if (a.timeout) conn.timeout = Number(a.timeout);
|
|
3103
|
+
const result = await sshExec(conn, fullCmd, proxy, stdin !== void 0 ? { stdin } : void 0);
|
|
3104
|
+
const output = [`Exit code: ${result.exitCode}`];
|
|
3105
|
+
if (result.stdout) output.push(`--- stdout ---
|
|
3106
|
+
${result.stdout}`);
|
|
3107
|
+
if (result.stderr) output.push(`--- stderr ---
|
|
3108
|
+
${result.stderr}`);
|
|
3109
|
+
return { content: [{ type: "text", text: output.join("\n") }] };
|
|
3110
|
+
}
|
|
3111
|
+
case "docker-compose": {
|
|
3112
|
+
const projectPath = String(a.projectPath);
|
|
3113
|
+
if (!projectPath || !projectPath.startsWith("/")) {
|
|
3114
|
+
return { content: [{ type: "text", text: "Error: projectPath must be an absolute path" }] };
|
|
3115
|
+
}
|
|
3116
|
+
const action = String(a.action);
|
|
3117
|
+
const allowedActions = ["up", "down", "restart", "logs", "ps", "pull", "build"];
|
|
3118
|
+
if (!allowedActions.includes(action)) {
|
|
3119
|
+
return { content: [{ type: "text", text: `Error: invalid action (allowed: ${allowedActions.join(", ")})` }] };
|
|
3120
|
+
}
|
|
3121
|
+
const service = typeof a.service === "string" && a.service ? a.service : "";
|
|
3122
|
+
const tail = Number(a.tail) > 0 ? Number(a.tail) : 200;
|
|
3123
|
+
let composeArgs;
|
|
3124
|
+
switch (action) {
|
|
3125
|
+
case "up":
|
|
3126
|
+
composeArgs = service ? ["up", "-d", service] : ["up", "-d"];
|
|
3127
|
+
break;
|
|
3128
|
+
case "down":
|
|
3129
|
+
composeArgs = service ? ["down", service] : ["down"];
|
|
3130
|
+
break;
|
|
3131
|
+
case "restart":
|
|
3132
|
+
composeArgs = service ? ["restart", service] : ["restart"];
|
|
3133
|
+
break;
|
|
3134
|
+
case "logs":
|
|
3135
|
+
composeArgs = service ? ["logs", "--no-color", `--tail=${tail}`, service] : ["logs", "--no-color", `--tail=${tail}`];
|
|
3136
|
+
break;
|
|
3137
|
+
case "ps":
|
|
3138
|
+
composeArgs = service ? ["ps", service] : ["ps"];
|
|
3139
|
+
break;
|
|
3140
|
+
case "pull":
|
|
3141
|
+
composeArgs = service ? ["pull", service] : ["pull"];
|
|
3142
|
+
break;
|
|
3143
|
+
case "build":
|
|
3144
|
+
composeArgs = service ? ["build", service] : ["build"];
|
|
3145
|
+
break;
|
|
3146
|
+
default:
|
|
3147
|
+
composeArgs = [];
|
|
3148
|
+
}
|
|
3149
|
+
const composeCmd = buildPosixCommand("docker", ["compose", ...composeArgs]);
|
|
3150
|
+
const fullCmd = `cd ${posixQuote(projectPath)} && ${composeCmd} 2>&1`;
|
|
3151
|
+
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
3152
|
+
conn.timeout = Number(a.timeout) > 0 ? Number(a.timeout) : 12e4;
|
|
3153
|
+
const result = await sshExec(conn, fullCmd, proxy);
|
|
3154
|
+
const output = [`Exit code: ${result.exitCode} (compose ${action}${service ? ` ${service}` : ""} @ ${projectPath})`];
|
|
3155
|
+
if (result.stdout) output.push(result.stdout);
|
|
3156
|
+
if (result.stderr) output.push(`--- stderr ---
|
|
3157
|
+
${result.stderr}`);
|
|
3158
|
+
return { content: [{ type: "text", text: output.join("\n") }] };
|
|
2847
3159
|
}
|
|
2848
3160
|
// ----- Database -----
|
|
2849
3161
|
case "db-discover": {
|