@mgsoftwarebv/mg-dashboard-mcp 6.2.0 → 6.4.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 +291 -39
- package/dist/index.js.map +1 -1
- package/package.json +1 -2
package/dist/index.js
CHANGED
|
@@ -7,6 +7,8 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
|
|
7
7
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
8
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
9
|
import { ListToolsRequestSchema, CallToolRequestSchema, isInitializeRequest, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, ListToolsResultSchema, CallToolResultSchema, ListPromptsResultSchema, GetPromptResultSchema, ListResourcesResultSchema, ReadResourceResultSchema, ListResourceTemplatesResultSchema, EmptyResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { createServer as createServer$1 } from 'net';
|
|
11
|
+
import { Client } from 'ssh2';
|
|
10
12
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
13
|
import { createServer } from 'http';
|
|
12
14
|
import { randomUUID, createHash, randomBytes, createDecipheriv, createCipheriv } from 'crypto';
|
|
@@ -15,7 +17,6 @@ import { drizzle } from 'drizzle-orm/postgres-js';
|
|
|
15
17
|
import postgres from 'postgres';
|
|
16
18
|
import { readFile, mkdtemp, writeFile, rm } from 'fs/promises';
|
|
17
19
|
import { tmpdir } from 'os';
|
|
18
|
-
import { Client } from 'ssh2';
|
|
19
20
|
import { HeadObjectCommand, S3Client, ListObjectsV2Command, DeleteObjectsCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand, GetObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
|
|
20
21
|
|
|
21
22
|
var __defProp = Object.defineProperty;
|
|
@@ -272,6 +273,181 @@ var init_proxy_mode = __esm({
|
|
|
272
273
|
"src/proxy-mode.ts"() {
|
|
273
274
|
}
|
|
274
275
|
});
|
|
276
|
+
|
|
277
|
+
// src/db-ssh-tunnel.ts
|
|
278
|
+
var db_ssh_tunnel_exports = {};
|
|
279
|
+
__export(db_ssh_tunnel_exports, {
|
|
280
|
+
fetchRemoteEnvValue: () => fetchRemoteEnvValue,
|
|
281
|
+
openDbSshTunnel: () => openDbSshTunnel
|
|
282
|
+
});
|
|
283
|
+
function expandHome2(path) {
|
|
284
|
+
if (!path.startsWith("~")) return path;
|
|
285
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
286
|
+
return join(home, path.slice(1));
|
|
287
|
+
}
|
|
288
|
+
function resolvePrivateKeyPath(input) {
|
|
289
|
+
const candidate = expandHome2(input);
|
|
290
|
+
const stripped = candidate.endsWith(".pub") ? candidate.slice(0, -4) : candidate;
|
|
291
|
+
if (existsSync(stripped) && statSync(stripped).isFile()) return stripped;
|
|
292
|
+
throw new Error(
|
|
293
|
+
`SSH private key not found at ${stripped}. The --db-ssh-tunnel flag needs the private key (not just .pub) to open the tunnel.`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
function parseSshTarget(target) {
|
|
297
|
+
const at = target.indexOf("@");
|
|
298
|
+
if (at === -1) {
|
|
299
|
+
throw new Error(`--db-ssh-tunnel must be in the form user@host[:port], got: ${target}`);
|
|
300
|
+
}
|
|
301
|
+
const username = target.slice(0, at);
|
|
302
|
+
const hostPart = target.slice(at + 1);
|
|
303
|
+
const colon = hostPart.lastIndexOf(":");
|
|
304
|
+
if (colon === -1) return { username, host: hostPart, port: 22 };
|
|
305
|
+
const port = Number(hostPart.slice(colon + 1));
|
|
306
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
307
|
+
throw new Error(`Invalid SSH port in --db-ssh-tunnel: ${hostPart.slice(colon + 1)}`);
|
|
308
|
+
}
|
|
309
|
+
return { username, host: hostPart.slice(0, colon), port };
|
|
310
|
+
}
|
|
311
|
+
function rewriteUrl(originalUrl, localPort) {
|
|
312
|
+
const parsed = new URL(originalUrl);
|
|
313
|
+
parsed.hostname = "127.0.0.1";
|
|
314
|
+
parsed.port = String(localPort);
|
|
315
|
+
return parsed.toString();
|
|
316
|
+
}
|
|
317
|
+
async function connectSsh(opts) {
|
|
318
|
+
return await new Promise((resolve, reject) => {
|
|
319
|
+
const conn = new Client();
|
|
320
|
+
const onError = (err) => {
|
|
321
|
+
conn.removeAllListeners();
|
|
322
|
+
reject(err);
|
|
323
|
+
};
|
|
324
|
+
conn.once("ready", () => {
|
|
325
|
+
conn.removeListener("error", onError);
|
|
326
|
+
resolve(conn);
|
|
327
|
+
});
|
|
328
|
+
conn.once("error", onError);
|
|
329
|
+
conn.connect({
|
|
330
|
+
host: opts.host,
|
|
331
|
+
port: opts.port,
|
|
332
|
+
username: opts.username,
|
|
333
|
+
privateKey: opts.privateKey,
|
|
334
|
+
keepaliveInterval: opts.keepaliveIntervalMs,
|
|
335
|
+
keepaliveCountMax: 3,
|
|
336
|
+
readyTimeout: 2e4
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
function execOverSsh(conn, cmd) {
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
conn.exec(cmd, (err, stream) => {
|
|
343
|
+
if (err) {
|
|
344
|
+
reject(err);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
let stdout = "";
|
|
348
|
+
let stderr = "";
|
|
349
|
+
stream.on("data", (chunk) => {
|
|
350
|
+
stdout += chunk.toString("utf8");
|
|
351
|
+
});
|
|
352
|
+
stream.stderr.on("data", (chunk) => {
|
|
353
|
+
stderr += chunk.toString("utf8");
|
|
354
|
+
});
|
|
355
|
+
stream.on("close", (code) => {
|
|
356
|
+
resolve({ stdout, stderr, code: code ?? -1 });
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function parseEnvValue(content, key) {
|
|
362
|
+
const re = new RegExp(`^${key}\\s*=\\s*(.*)$`, "m");
|
|
363
|
+
const m = content.match(re);
|
|
364
|
+
if (!m || !m[1]) return void 0;
|
|
365
|
+
let value = m[1].trim();
|
|
366
|
+
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value[value.length - 1] === value[0]) {
|
|
367
|
+
value = value.slice(1, -1);
|
|
368
|
+
}
|
|
369
|
+
return value;
|
|
370
|
+
}
|
|
371
|
+
async function fetchRemoteEnvValue(options) {
|
|
372
|
+
const { username, host, port } = parseSshTarget(options.sshTarget);
|
|
373
|
+
const privateKeyPath = resolvePrivateKeyPath(options.sshKeyPath);
|
|
374
|
+
const privateKey = readFileSync(privateKeyPath);
|
|
375
|
+
const keepaliveIntervalMs = options.keepaliveIntervalMs ?? 3e4;
|
|
376
|
+
const envKey = options.envKey ?? "DATABASE_PRIMARY_URL";
|
|
377
|
+
const conn = await connectSsh({ host, port, username, privateKey, keepaliveIntervalMs });
|
|
378
|
+
try {
|
|
379
|
+
const safePath = options.remoteEnvPath.replace(/'/g, "'\\''");
|
|
380
|
+
const result = await execOverSsh(conn, `cat -- '${safePath}'`);
|
|
381
|
+
if (result.code !== 0) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`remote cat ${options.remoteEnvPath} failed (exit ${result.code}): ${result.stderr.trim()}`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const value = parseEnvValue(result.stdout, envKey);
|
|
387
|
+
if (!value) {
|
|
388
|
+
throw new Error(`${envKey} not found in ${options.remoteEnvPath}`);
|
|
389
|
+
}
|
|
390
|
+
return value;
|
|
391
|
+
} finally {
|
|
392
|
+
conn.end();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function openDbSshTunnel(options) {
|
|
396
|
+
const { username, host, port } = parseSshTarget(options.sshTarget);
|
|
397
|
+
const privateKeyPath = resolvePrivateKeyPath(options.sshKeyPath);
|
|
398
|
+
const privateKey = readFileSync(privateKeyPath);
|
|
399
|
+
const keepaliveIntervalMs = options.keepaliveIntervalMs ?? 3e4;
|
|
400
|
+
const parsedDbUrl = new URL(options.databaseUrl);
|
|
401
|
+
const remoteHost = parsedDbUrl.hostname;
|
|
402
|
+
const remotePort = Number(parsedDbUrl.port || "5432");
|
|
403
|
+
const conn = await connectSsh({ host, port, username, privateKey, keepaliveIntervalMs });
|
|
404
|
+
conn.on("error", (err) => {
|
|
405
|
+
console.error(`[mcp][db-ssh-tunnel] ssh connection error: ${err.message}`);
|
|
406
|
+
});
|
|
407
|
+
conn.on("close", () => {
|
|
408
|
+
console.error("[mcp][db-ssh-tunnel] ssh connection closed; MCP will exit so Cursor can respawn it");
|
|
409
|
+
process.exit(1);
|
|
410
|
+
});
|
|
411
|
+
const server2 = createServer$1((local) => {
|
|
412
|
+
conn.forwardOut("127.0.0.1", 0, remoteHost, remotePort, (err, stream) => {
|
|
413
|
+
if (err) {
|
|
414
|
+
console.error(`[mcp][db-ssh-tunnel] forwardOut failed: ${err.message}`);
|
|
415
|
+
local.destroy(err);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
local.on("error", () => stream.destroy());
|
|
419
|
+
stream.on("error", () => local.destroy());
|
|
420
|
+
local.pipe(stream).pipe(local);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
const localPort = await new Promise((resolve, reject) => {
|
|
424
|
+
server2.once("error", reject);
|
|
425
|
+
server2.listen(0, "127.0.0.1", () => {
|
|
426
|
+
const addr = server2.address();
|
|
427
|
+
if (!addr || typeof addr === "string") {
|
|
428
|
+
reject(new Error("Failed to bind local tunnel listener"));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
resolve(addr.port);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
const rewrittenDatabaseUrl = rewriteUrl(options.databaseUrl, localPort);
|
|
435
|
+
console.error(
|
|
436
|
+
`[mcp][db-ssh-tunnel] forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort} via ssh ${username}@${host}:${port}`
|
|
437
|
+
);
|
|
438
|
+
return {
|
|
439
|
+
rewrittenDatabaseUrl,
|
|
440
|
+
localPort,
|
|
441
|
+
async close() {
|
|
442
|
+
await new Promise((resolve) => server2.close(() => resolve()));
|
|
443
|
+
conn.end();
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
var init_db_ssh_tunnel = __esm({
|
|
448
|
+
"src/db-ssh-tunnel.ts"() {
|
|
449
|
+
}
|
|
450
|
+
});
|
|
275
451
|
var connectionConfig = {
|
|
276
452
|
prepare: false,
|
|
277
453
|
idle_timeout: 20,
|
|
@@ -1979,19 +2155,42 @@ var sshKeyPath = getArg2("ssh-key") || process.env.MG_DASHBOARD_SSH_KEY;
|
|
|
1979
2155
|
var databaseUrl = getArg2("database-url") || process.env.DATABASE_PRIMARY_POOLER_URL || process.env.DATABASE_PRIMARY_URL;
|
|
1980
2156
|
var encryptionKey = getArg2("encryption-key") || process.env.ENCRYPTION_KEY;
|
|
1981
2157
|
var mijnhostApiKey = getArg2("mijnhost-api-key") || process.env.MIJNHOST_API_KEY;
|
|
2158
|
+
var dbSshTunnel = getArg2("db-ssh-tunnel") || process.env.MG_DASHBOARD_DB_SSH_TUNNEL;
|
|
2159
|
+
var dbRemoteEnvFile = getArg2("db-remote-env-file") || process.env.MG_DASHBOARD_DB_REMOTE_ENV_FILE;
|
|
2160
|
+
var dbRemoteEnvKey = getArg2("db-remote-env-key") || process.env.MG_DASHBOARD_DB_REMOTE_ENV_KEY || "DATABASE_PRIMARY_URL";
|
|
1982
2161
|
var httpMode = args.includes("--http");
|
|
1983
2162
|
var httpPort = Number(getArg2("port")) || 3100;
|
|
1984
2163
|
if (!apiKey || !sshKeyPath) {
|
|
1985
2164
|
console.error("Authentication required. Use both --api-key=dk_xxx and --ssh-key=PATH (path to your SSH private or public key), or set MG_DASHBOARD_API_KEY and MG_DASHBOARD_SSH_KEY.");
|
|
1986
2165
|
process.exit(1);
|
|
1987
2166
|
}
|
|
2167
|
+
if (dbRemoteEnvFile && !dbSshTunnel) {
|
|
2168
|
+
console.error("--db-remote-env-file requires --db-ssh-tunnel (the same SSH connection is used to read the env value).");
|
|
2169
|
+
process.exit(1);
|
|
2170
|
+
}
|
|
2171
|
+
if (dbRemoteEnvFile) {
|
|
2172
|
+
const { fetchRemoteEnvValue: fetchRemoteEnvValue2 } = await Promise.resolve().then(() => (init_db_ssh_tunnel(), db_ssh_tunnel_exports));
|
|
2173
|
+
databaseUrl = await fetchRemoteEnvValue2({
|
|
2174
|
+
sshTarget: dbSshTunnel,
|
|
2175
|
+
sshKeyPath,
|
|
2176
|
+
remoteEnvPath: dbRemoteEnvFile,
|
|
2177
|
+
envKey: dbRemoteEnvKey
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
1988
2180
|
if (!databaseUrl) {
|
|
1989
|
-
console.error("Database URL required.
|
|
2181
|
+
console.error("Database URL required. Provide one of:\n --database-url=postgres://...\n --db-remote-env-file=/path/.env (combined with --db-ssh-tunnel)\n DATABASE_PRIMARY_URL or DATABASE_PRIMARY_POOLER_URL env var");
|
|
1990
2182
|
process.exit(1);
|
|
1991
2183
|
}
|
|
1992
|
-
if (
|
|
1993
|
-
|
|
2184
|
+
if (dbSshTunnel) {
|
|
2185
|
+
const { openDbSshTunnel: openDbSshTunnel2 } = await Promise.resolve().then(() => (init_db_ssh_tunnel(), db_ssh_tunnel_exports));
|
|
2186
|
+
const tunnel = await openDbSshTunnel2({ sshTarget: dbSshTunnel, sshKeyPath, databaseUrl });
|
|
2187
|
+
databaseUrl = tunnel.rewrittenDatabaseUrl;
|
|
2188
|
+
process.on("exit", () => {
|
|
2189
|
+
void tunnel.close();
|
|
2190
|
+
});
|
|
1994
2191
|
}
|
|
2192
|
+
process.env.DATABASE_PRIMARY_URL = databaseUrl;
|
|
2193
|
+
process.env.DATABASE_PRIMARY_POOLER_URL = databaseUrl;
|
|
1995
2194
|
var db = getDb();
|
|
1996
2195
|
var RateLimiter = class {
|
|
1997
2196
|
buckets = /* @__PURE__ */ new Map();
|
|
@@ -2079,7 +2278,6 @@ async function writeAuditLog(entry) {
|
|
|
2079
2278
|
var MODULE_KEYS = [
|
|
2080
2279
|
"users",
|
|
2081
2280
|
"ssh_servers",
|
|
2082
|
-
"supabase",
|
|
2083
2281
|
"wiki",
|
|
2084
2282
|
"ci_cd",
|
|
2085
2283
|
"domains",
|
|
@@ -2088,7 +2286,7 @@ var MODULE_KEYS = [
|
|
|
2088
2286
|
];
|
|
2089
2287
|
var FULL_PERMISSIONS = {
|
|
2090
2288
|
modules: Object.fromEntries(MODULE_KEYS.map((k) => [k, true])),
|
|
2091
|
-
resources: { ssh_servers: ["*"]
|
|
2289
|
+
resources: { ssh_servers: ["*"] }
|
|
2092
2290
|
};
|
|
2093
2291
|
function parsePermissions(raw) {
|
|
2094
2292
|
if (!raw || typeof raw !== "object") return null;
|
|
@@ -2105,8 +2303,7 @@ function resolvePermissions(roleName, roleDefaults, userOverrides) {
|
|
|
2105
2303
|
modules[key] = userVal !== void 0 ? userVal : roleVal !== void 0 ? roleVal : false;
|
|
2106
2304
|
}
|
|
2107
2305
|
const resources = {
|
|
2108
|
-
ssh_servers: overrides?.resources?.ssh_servers ?? base?.resources?.ssh_servers ?? []
|
|
2109
|
-
supabase_instances: overrides?.resources?.supabase_instances ?? base?.resources?.supabase_instances ?? []
|
|
2306
|
+
ssh_servers: overrides?.resources?.ssh_servers ?? base?.resources?.ssh_servers ?? []
|
|
2110
2307
|
};
|
|
2111
2308
|
return { modules, resources };
|
|
2112
2309
|
}
|
|
@@ -2750,7 +2947,7 @@ async function getServerConnection(serverIdOrName) {
|
|
|
2750
2947
|
const needsProxy = data.allowed_ssh_ips !== null && serverId !== SSH_PROXY_SERVER_ID;
|
|
2751
2948
|
const proxy = needsProxy ? await getProxyConnection() : void 0;
|
|
2752
2949
|
const os = data.os_type === "windows" ? "windows" : "linux";
|
|
2753
|
-
return { conn, proxy, os };
|
|
2950
|
+
return { serverId, conn, proxy, os };
|
|
2754
2951
|
}
|
|
2755
2952
|
async function sshExec(opts, command, proxy, options) {
|
|
2756
2953
|
const first = await sshExecOnce(opts, command, proxy, options);
|
|
@@ -4232,6 +4429,34 @@ done
|
|
|
4232
4429
|
}
|
|
4233
4430
|
return out;
|
|
4234
4431
|
}
|
|
4432
|
+
var POSTGRES_CONTAINER_CREDS_CACHE = /* @__PURE__ */ new Map();
|
|
4433
|
+
var PG_CREDS_TTL_MS = 60 * 60 * 1e3;
|
|
4434
|
+
function cachePostgresContainerCreds(serverId, containerName, creds) {
|
|
4435
|
+
POSTGRES_CONTAINER_CREDS_CACHE.set(`${serverId}|${containerName}`, { ...creds, capturedAt: Date.now() });
|
|
4436
|
+
}
|
|
4437
|
+
async function resolvePostgresContainerCreds(conn, proxy, serverId, containerName) {
|
|
4438
|
+
const key = `${serverId}|${containerName}`;
|
|
4439
|
+
const cached = POSTGRES_CONTAINER_CREDS_CACHE.get(key);
|
|
4440
|
+
if (cached && Date.now() - cached.capturedAt < PG_CREDS_TTL_MS) return cached;
|
|
4441
|
+
const safeContainer = containerName.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
4442
|
+
const res = await sshExec(
|
|
4443
|
+
conn,
|
|
4444
|
+
`docker exec ${safeContainer} env 2>/dev/null | grep -E '^POSTGRES_(USER|DB|PASSWORD)='`,
|
|
4445
|
+
proxy
|
|
4446
|
+
);
|
|
4447
|
+
let dbUser = "postgres";
|
|
4448
|
+
let dbName = "postgres";
|
|
4449
|
+
let hasPassword = false;
|
|
4450
|
+
for (const raw of res.stdout.split("\n")) {
|
|
4451
|
+
const line = raw.trim();
|
|
4452
|
+
if (line.startsWith("POSTGRES_USER=")) dbUser = line.slice("POSTGRES_USER=".length) || "postgres";
|
|
4453
|
+
else if (line.startsWith("POSTGRES_DB=")) dbName = line.slice("POSTGRES_DB=".length) || "postgres";
|
|
4454
|
+
else if (line.startsWith("POSTGRES_PASSWORD=")) hasPassword = true;
|
|
4455
|
+
}
|
|
4456
|
+
const creds = { dbName, dbUser, hasPassword, capturedAt: Date.now() };
|
|
4457
|
+
POSTGRES_CONTAINER_CREDS_CACHE.set(key, creds);
|
|
4458
|
+
return creds;
|
|
4459
|
+
}
|
|
4235
4460
|
async function psqlInContainer(conn, proxy, containerName, dbUser, dbName, scriptSql, flags) {
|
|
4236
4461
|
const safeContainer = containerName.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
4237
4462
|
const safeUser = dbUser.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
|
|
@@ -4687,22 +4912,22 @@ var TOOLS = [
|
|
|
4687
4912
|
},
|
|
4688
4913
|
{
|
|
4689
4914
|
name: "db-tables",
|
|
4690
|
-
description: "List tables with row counts + sizes. Two routing modes:\n- MySQL via `/var/www` autodiscover: pass `sitePath`. Credentials are read from wp-config.php / parameters.php / .env.\n- Postgres container: pass `containerName
|
|
4915
|
+
description: "List tables with row counts + sizes. Two routing modes:\n- MySQL via `/var/www` autodiscover: pass `sitePath`. Credentials are read from wp-config.php / parameters.php / .env.\n- Postgres container: pass `containerName`. dbName + dbUser are auto-resolved from the container `POSTGRES_DB` / `POSTGRES_USER` env vars (cached 1h). Returns one row per user table sorted by total size, with estimated row count from pg_class.reltuples.",
|
|
4691
4916
|
inputSchema: {
|
|
4692
4917
|
type: "object",
|
|
4693
4918
|
properties: {
|
|
4694
|
-
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
4919
|
+
serverId: { type: "string", description: "UUID or (fuzzy) name of the SSH server" },
|
|
4695
4920
|
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). MySQL autodiscover mode." },
|
|
4696
4921
|
containerName: { type: "string", description: "Postgres container name. Activates direct Postgres mode." },
|
|
4697
|
-
dbName: { type: "string", description:
|
|
4698
|
-
dbUser: { type: "string", description:
|
|
4922
|
+
dbName: { type: "string", description: "Database name (containerName mode). Auto-resolved from container env when omitted." },
|
|
4923
|
+
dbUser: { type: "string", description: "Database user (containerName mode). Auto-resolved from container env when omitted." }
|
|
4699
4924
|
},
|
|
4700
4925
|
required: ["serverId"]
|
|
4701
4926
|
}
|
|
4702
4927
|
},
|
|
4703
4928
|
{
|
|
4704
4929
|
name: "db-query",
|
|
4705
|
-
description: 'Execute SQL against any database. **Use this for ALL queries (SELECT, INSERT, UPDATE, DELETE, schema introspection) against vanilla Postgres / MySQL / MSSQL containers** \u2014 do NOT fall back to `ssh-execute` + `docker cp` + `psql -f` with .tmp.sql files; the container path here already pipes via stdin so quotes / `$` / `;` are safe. Multi-statement scripts work fine: separate with `;` and they will all be executed by psql/mysql.\n\nTwo routing modes:\n- MySQL via `/var/www` autodiscover (default): pass `sitePath` (and `engine: "mysql"` implicitly). Credentials are read from wp-config.php, parameters.php, .env.\n- Direct via Docker container: pass `containerName` (the container running the DB server). Engine defaults to `postgres`; pass `engine: "mysql"` or `"mssql"` to override. Use `db-discover include=["postgres-containers"]` to find available containers + creds.\n\nPostgres example: `{ serverId, containerName: "refront-postgres-vanilla",
|
|
4930
|
+
description: 'Execute SQL against any database. **Use this for ALL queries (SELECT, INSERT, UPDATE, DELETE, schema introspection) against vanilla Postgres / MySQL / MSSQL containers** \u2014 do NOT fall back to `ssh-execute` + `docker cp` + `psql -f` with .tmp.sql files; the container path here already pipes via stdin so quotes / `$` / `;` are safe. Multi-statement scripts work fine: separate with `;` and they will all be executed by psql/mysql.\n\nTwo routing modes:\n- MySQL via `/var/www` autodiscover (default): pass `sitePath` (and `engine: "mysql"` implicitly). Credentials are read from wp-config.php, parameters.php, .env.\n- Direct via Docker container: pass `containerName` (the container running the DB server). Engine defaults to `postgres`; pass `engine: "mysql"` or `"mssql"` to override. Use `db-discover include=["postgres-containers"]` to find available containers + creds.\n\nPostgres example: `{ serverId, containerName: "refront-postgres-vanilla", query: "SELECT 1" }` \u2014 dbName and dbUser are AUTO-RESOLVED from the container\'s `POSTGRES_USER` / `POSTGRES_DB` env vars (cached 1h per server). Only pass `dbName`/`dbUser` explicitly when the instance hosts multiple databases or you want to query as a non-default role.\nMSSQL example: `{ serverId, containerName: "mssql-1", engine: "mssql", dbUser: "sa", dbPass: "...", query: "SELECT 1" }`.\n\nResult safety: `maxRows` (default 1000, max 10000) auto-injects a `LIMIT` for SELECT queries that don\'t have one. The footer reports whether the cap was applied. Pass `maxRows: 0` to disable.\n\nDiagnostics: `explain: true` prepends EXPLAIN (postgres / mysql) or runs `SET SHOWPLAN_TEXT ON` (mssql).\n\nAtomic multi-statement: pass `transaction: true` to wrap the query in BEGIN/COMMIT (postgres / mysql) so a failure in any statement rolls back the lot. Use for ad-hoc fixes that are not schema migrations (e.g. `UPDATE tax_rate SET pct = pct * 100; UPDATE order SET ...`).\n\nSchema introspection: pass `describe: "tablename"` to get columns + indexes (works for mysql, postgres, and mssql). Pass `describe: "*"` to list all tables. When `describe` is set, `query` is ignored.\n\nDestructive operations (DROP, TRUNCATE, ALTER \u2026 DROP, naked DELETE) are blocked by default. For schema migrations, **prefer `db-apply-migration`** \u2014 it has an audit ledger and idempotency. As an escape hatch for ad-hoc hot-fixes, pass `allowDestructive: "yes-i-understand-this-is-not-logged"` (literal string) to bypass the gate. The bypass is logged in the response footer.',
|
|
4706
4931
|
inputSchema: {
|
|
4707
4932
|
type: "object",
|
|
4708
4933
|
properties: {
|
|
@@ -4712,8 +4937,8 @@ var TOOLS = [
|
|
|
4712
4937
|
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
|
|
4713
4938
|
containerName: { type: "string", description: 'Docker container running the DB server (e.g. "refront-postgres-vanilla", "supabase-db"). Activates direct-query mode.' },
|
|
4714
4939
|
engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
|
|
4715
|
-
dbName: { type: "string", description:
|
|
4716
|
-
dbUser: { type: "string", description: 'Database user (containerName mode).
|
|
4940
|
+
dbName: { type: "string", description: "Database name (containerName mode). For postgres: auto-resolved from container `POSTGRES_DB` env var when omitted. For mysql: server default. For mssql: server default." },
|
|
4941
|
+
dbUser: { type: "string", description: 'Database user (containerName mode). For postgres: auto-resolved from container `POSTGRES_USER` env var when omitted. For mysql: defaults to "root". For mssql: defaults to "sa".' },
|
|
4717
4942
|
dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." },
|
|
4718
4943
|
maxRows: { type: "number", description: "Auto-inject LIMIT for unbounded SELECTs. Default 1000, max 10000, set 0 to disable." },
|
|
4719
4944
|
explain: { type: "boolean", description: "Prepend EXPLAIN (postgres/mysql) or SHOWPLAN (mssql) instead of executing. Useful for slow-query diagnosis." },
|
|
@@ -4725,14 +4950,14 @@ var TOOLS = [
|
|
|
4725
4950
|
},
|
|
4726
4951
|
{
|
|
4727
4952
|
name: "db-apply-migration",
|
|
4728
|
-
description: 'Apply a SQL migration to a Postgres container and record it in an MCP-managed ledger table (`_mcp_migrations`) so re-runs are idempotent. Use this for ALL schema changes (CREATE/ALTER/DROP/TRUNCATE) \u2014 it allows DDL that `db-query` blocks, and gives you audit + drift detection in exchange.\n\nLedger schema (auto-created on first call): `_mcp_migrations(name TEXT PRIMARY KEY, sha256 TEXT, applied_at TIMESTAMPTZ, applied_by TEXT)`.\n\nBehaviour:\n- If `name` is already in the ledger AND the sha256 of the supplied SQL matches \u2192 no-op, returns "already applied".\n- If `name` is already in the ledger with a DIFFERENT sha256 \u2192 fails loudly ("drift detected"). Pass `force: true` to overwrite (logged).\n- Otherwise the SQL is wrapped in BEGIN/COMMIT and applied, then a row is inserted into `_mcp_migrations`.\n\nPass `noTransaction: true` for statements that cannot run inside a transaction (`CREATE INDEX CONCURRENTLY`, `ALTER TYPE \u2026 ADD VALUE`, `VACUUM`, etc.). In that mode the user SQL runs first and the ledger is recorded in a separate follow-up call.\n\nSQL source: provide ONE of `sql` (inline string), `localFile` (path on this machine, read and piped via stdin), or `remoteFile` (absolute path on the SSH server, read with `cat` first).\n\nExample: `{ serverId, containerName: "refront-postgres-vanilla",
|
|
4953
|
+
description: 'Apply a SQL migration to a Postgres container and record it in an MCP-managed ledger table (`_mcp_migrations`) so re-runs are idempotent. Use this for ALL schema changes (CREATE/ALTER/DROP/TRUNCATE) \u2014 it allows DDL that `db-query` blocks, and gives you audit + drift detection in exchange.\n\nLedger schema (auto-created on first call): `_mcp_migrations(name TEXT PRIMARY KEY, sha256 TEXT, applied_at TIMESTAMPTZ, applied_by TEXT)`.\n\nBehaviour:\n- If `name` is already in the ledger AND the sha256 of the supplied SQL matches \u2192 no-op, returns "already applied".\n- If `name` is already in the ledger with a DIFFERENT sha256 \u2192 fails loudly ("drift detected"). Pass `force: true` to overwrite (logged).\n- Otherwise the SQL is wrapped in BEGIN/COMMIT and applied, then a row is inserted into `_mcp_migrations`.\n\nPass `noTransaction: true` for statements that cannot run inside a transaction (`CREATE INDEX CONCURRENTLY`, `ALTER TYPE \u2026 ADD VALUE`, `VACUUM`, etc.). In that mode the user SQL runs first and the ledger is recorded in a separate follow-up call.\n\nSQL source: provide ONE of `sql` (inline string), `localFile` (path on this machine, read and piped via stdin), or `remoteFile` (absolute path on the SSH server, read with `cat` first).\n\nExample: `{ serverId, containerName: "refront-postgres-vanilla", name: "20260517120000_add_unified_classification", localFile: "./migrations/20260517120000_add_unified_classification.sql" }` \u2014 dbName + dbUser are auto-resolved from the container env (cached 1h).',
|
|
4729
4954
|
inputSchema: {
|
|
4730
4955
|
type: "object",
|
|
4731
4956
|
properties: {
|
|
4732
|
-
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
4957
|
+
serverId: { type: "string", description: "UUID or (fuzzy) name of the SSH server" },
|
|
4733
4958
|
containerName: { type: "string", description: 'Postgres container name (e.g. "refront-postgres-vanilla")' },
|
|
4734
|
-
dbName: { type: "string", description:
|
|
4735
|
-
dbUser: { type: "string", description:
|
|
4959
|
+
dbName: { type: "string", description: "Database. Auto-resolved from container `POSTGRES_DB` env when omitted. Override only when the instance hosts multiple databases." },
|
|
4960
|
+
dbUser: { type: "string", description: "User. Auto-resolved from container `POSTGRES_USER` env when omitted." },
|
|
4736
4961
|
name: { type: "string", description: "Unique migration name (recommended format: `YYYYMMDDhhmmss_description`)" },
|
|
4737
4962
|
sql: { type: "string", description: "Inline SQL string (mutually exclusive with localFile / remoteFile)" },
|
|
4738
4963
|
localFile: { type: "string", description: "Path on this machine to a .sql file (read here, streamed via stdin)" },
|
|
@@ -4746,14 +4971,14 @@ var TOOLS = [
|
|
|
4746
4971
|
},
|
|
4747
4972
|
{
|
|
4748
4973
|
name: "db-list-migrations",
|
|
4749
|
-
description: 'List entries from the `_mcp_migrations` ledger on a Postgres container. Answers "what migrations have already been applied here?" without having to write SQL. Returns name, sha256
|
|
4974
|
+
description: 'List entries from the `_mcp_migrations` ledger on a Postgres container. Answers "what migrations have already been applied here?" without having to write SQL. Returns full name, sha256, applied_at, applied_by, ordered by most recent first. If the ledger table does not exist yet (no migrations applied via db-apply-migration), returns a friendly empty response. dbName + dbUser are auto-resolved from the container env when omitted.',
|
|
4750
4975
|
inputSchema: {
|
|
4751
4976
|
type: "object",
|
|
4752
4977
|
properties: {
|
|
4753
|
-
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
4978
|
+
serverId: { type: "string", description: "UUID or (fuzzy) name of the SSH server" },
|
|
4754
4979
|
containerName: { type: "string", description: "Postgres container name" },
|
|
4755
|
-
dbName: { type: "string", description:
|
|
4756
|
-
dbUser: { type: "string", description:
|
|
4980
|
+
dbName: { type: "string", description: "Database. Auto-resolved from container `POSTGRES_DB` env when omitted." },
|
|
4981
|
+
dbUser: { type: "string", description: "User. Auto-resolved from container `POSTGRES_USER` env when omitted." },
|
|
4757
4982
|
limit: { type: "number", description: "Max number of entries (default 50, max 500)" }
|
|
4758
4983
|
},
|
|
4759
4984
|
required: ["serverId", "containerName"]
|
|
@@ -4858,7 +5083,7 @@ var TOOLS = [
|
|
|
4858
5083
|
// ----- Repo reference -----
|
|
4859
5084
|
...REPO_TOOLS
|
|
4860
5085
|
];
|
|
4861
|
-
var MCP_VERSION = "6.
|
|
5086
|
+
var MCP_VERSION = "6.3.0";
|
|
4862
5087
|
async function handleListTools() {
|
|
4863
5088
|
if (!authContext) return { tools: TOOLS };
|
|
4864
5089
|
const accessible = TOOLS.filter((tool) => {
|
|
@@ -5814,7 +6039,7 @@ ${trail.join("\n")}` }] };
|
|
|
5814
6039
|
}
|
|
5815
6040
|
// ----- Database -----
|
|
5816
6041
|
case "db-discover": {
|
|
5817
|
-
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
6042
|
+
const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
|
|
5818
6043
|
const rawInclude = Array.isArray(a.include) ? a.include.map(String) : ["www"];
|
|
5819
6044
|
const include = new Set(rawInclude.filter((m) => m === "www" || m === "postgres-containers"));
|
|
5820
6045
|
if (include.size === 0) include.add("www");
|
|
@@ -5833,6 +6058,13 @@ ${lines.join("\n")}`);
|
|
|
5833
6058
|
}
|
|
5834
6059
|
if (include.has("postgres-containers")) {
|
|
5835
6060
|
const containers = await discoverPostgresContainers(conn, proxy);
|
|
6061
|
+
for (const c of containers) {
|
|
6062
|
+
cachePostgresContainerCreds(serverId, c.container, {
|
|
6063
|
+
dbName: c.db,
|
|
6064
|
+
dbUser: c.user,
|
|
6065
|
+
hasPassword: c.hasPassword
|
|
6066
|
+
});
|
|
6067
|
+
}
|
|
5836
6068
|
if (!containers.length) {
|
|
5837
6069
|
sections.push("## Postgres containers\n(none \u2014 no running container exposes POSTGRES_USER/POSTGRES_DB)");
|
|
5838
6070
|
} else {
|
|
@@ -5842,17 +6074,22 @@ ${lines.join("\n")}`);
|
|
|
5842
6074
|
sections.push(`## Postgres containers (${containers.length})
|
|
5843
6075
|
${lines.join("\n")}
|
|
5844
6076
|
|
|
5845
|
-
Tip:
|
|
6077
|
+
Tip: pass just \`containerName\` to \`db-query\` / \`db-apply-migration\` / \`db-list-migrations\` \u2014 dbName + dbUser are auto-resolved from the container env (cached for 1h per server).`);
|
|
5846
6078
|
}
|
|
5847
6079
|
}
|
|
5848
6080
|
return { content: [{ type: "text", text: sections.join("\n\n") }] };
|
|
5849
6081
|
}
|
|
5850
6082
|
case "db-tables": {
|
|
5851
|
-
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
6083
|
+
const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
|
|
5852
6084
|
const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName : "";
|
|
5853
6085
|
if (containerName) {
|
|
5854
|
-
|
|
5855
|
-
|
|
6086
|
+
let dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "";
|
|
6087
|
+
let dbName = typeof a.dbName === "string" && a.dbName ? a.dbName : "";
|
|
6088
|
+
if (!dbUser || !dbName) {
|
|
6089
|
+
const creds = await resolvePostgresContainerCreds(conn, proxy, serverId, containerName);
|
|
6090
|
+
if (!dbUser) dbUser = creds.dbUser;
|
|
6091
|
+
if (!dbName) dbName = creds.dbName;
|
|
6092
|
+
}
|
|
5856
6093
|
const sqlText2 = `SELECT n.nspname AS schema,
|
|
5857
6094
|
c.relname AS "table",
|
|
5858
6095
|
pg_size_pretty(pg_total_relation_size(c.oid)) AS size,
|
|
@@ -5948,9 +6185,14 @@ COMMIT;`;
|
|
|
5948
6185
|
if (!containerName) {
|
|
5949
6186
|
return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
|
|
5950
6187
|
}
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
6188
|
+
let dbName = typeof a.dbName === "string" && a.dbName ? a.dbName.replace(/[^a-zA-Z0-9_-]/g, "") : "";
|
|
6189
|
+
let dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") : "";
|
|
6190
|
+
const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
|
|
6191
|
+
if (engine === "postgres" && (!dbName || !dbUser)) {
|
|
6192
|
+
const creds = await resolvePostgresContainerCreds(conn, proxy, serverId, containerName);
|
|
6193
|
+
if (!dbName) dbName = creds.dbName.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
6194
|
+
if (!dbUser) dbUser = creds.dbUser.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
6195
|
+
}
|
|
5954
6196
|
let cmd;
|
|
5955
6197
|
let stdinPayload;
|
|
5956
6198
|
if (engine === "postgres") {
|
|
@@ -5988,8 +6230,8 @@ GO
|
|
|
5988
6230
|
case "db-apply-migration": {
|
|
5989
6231
|
const containerName = String(a.containerName || "").replace(/[^a-zA-Z0-9._-]/g, "");
|
|
5990
6232
|
if (!containerName) return { content: [{ type: "text", text: "Error: containerName is required" }] };
|
|
5991
|
-
|
|
5992
|
-
|
|
6233
|
+
let dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "").replace(/[^a-zA-Z0-9_-]/g, "");
|
|
6234
|
+
let dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "").replace(/[^a-zA-Z0-9_-]/g, "");
|
|
5993
6235
|
const name2 = String(a.name || "").trim();
|
|
5994
6236
|
if (!name2) return { content: [{ type: "text", text: 'Error: name is required (e.g. "20260517120000_add_foo")' }] };
|
|
5995
6237
|
if (!/^[\w.@:+\-]+$/.test(name2) || name2.length > 200) {
|
|
@@ -6008,7 +6250,12 @@ GO
|
|
|
6008
6250
|
if (sources.length !== 1) {
|
|
6009
6251
|
return { content: [{ type: "text", text: `Error: pass exactly one of \`sql\`, \`localFile\`, or \`remoteFile\` (got ${sources.length || "none"}: ${sources.join(", ") || "\u2014"})` }] };
|
|
6010
6252
|
}
|
|
6011
|
-
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
6253
|
+
const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
|
|
6254
|
+
if (!dbUser || !dbName) {
|
|
6255
|
+
const creds = await resolvePostgresContainerCreds(conn, proxy, serverId, containerName);
|
|
6256
|
+
if (!dbUser) dbUser = creds.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
|
|
6257
|
+
if (!dbName) dbName = creds.dbName.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
|
|
6258
|
+
}
|
|
6012
6259
|
let migrationSql;
|
|
6013
6260
|
if (typeof a.sql === "string" && a.sql) {
|
|
6014
6261
|
migrationSql = a.sql;
|
|
@@ -6168,11 +6415,16 @@ ${res.stdout.trim()}` : "(no output)")
|
|
|
6168
6415
|
case "db-list-migrations": {
|
|
6169
6416
|
const containerName = String(a.containerName || "").replace(/[^a-zA-Z0-9._-]/g, "");
|
|
6170
6417
|
if (!containerName) return { content: [{ type: "text", text: "Error: containerName is required" }] };
|
|
6171
|
-
|
|
6172
|
-
|
|
6418
|
+
let dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "").replace(/[^a-zA-Z0-9_-]/g, "");
|
|
6419
|
+
let dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "").replace(/[^a-zA-Z0-9_-]/g, "");
|
|
6173
6420
|
const limitRaw = Number(a.limit);
|
|
6174
6421
|
const limit = Math.min(Math.max(Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 50, 1), 500);
|
|
6175
|
-
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
6422
|
+
const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
|
|
6423
|
+
if (!dbUser || !dbName) {
|
|
6424
|
+
const creds = await resolvePostgresContainerCreds(conn, proxy, serverId, containerName);
|
|
6425
|
+
if (!dbUser) dbUser = creds.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
|
|
6426
|
+
if (!dbName) dbName = creds.dbName.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
|
|
6427
|
+
}
|
|
6176
6428
|
const sqlText = `SELECT name, sha256, applied_at, applied_by
|
|
6177
6429
|
FROM _mcp_migrations
|
|
6178
6430
|
ORDER BY applied_at DESC
|