@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 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. Use --database-url=postgres://... or set DATABASE_PRIMARY_URL (or DATABASE_PRIMARY_POOLER_URL).");
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 (!process.env.DATABASE_PRIMARY_URL && !process.env.DATABASE_PRIMARY_POOLER_URL) {
1993
- process.env.DATABASE_PRIMARY_URL = databaseUrl;
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: ["*"], supabase_instances: ["*"] }
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` (and optionally `dbName` / `dbUser`, defaulting to `postgres`). Returns one row per user table sorted by total size, with estimated row count from pg_class.reltuples.",
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: 'Database name (containerName mode, defaults to "postgres")' },
4698
- dbUser: { type: "string", description: 'Database user (containerName mode, defaults to "postgres")' }
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", dbName: "main", dbUser: "postgres", query: "SELECT 1" }`. Defaults: dbUser=postgres, dbName=postgres.\nMSSQL example: `{ serverId, containerName: "mssql-1", engine: "mssql", dbUser: "sa", dbPass: "...", query: "SELECT 1" }`.\n\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.',
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: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
4716
- dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
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", dbName: "main", name: "20260517120000_add_unified_classification", localFile: "./migrations/20260517120000_add_unified_classification.sql" }`.',
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: 'Database (defaults to "postgres" \u2014 set explicitly to your app DB)' },
4735
- dbUser: { type: "string", description: 'User (defaults to "postgres")' },
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 (truncated), 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 an empty list.',
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: 'Database (defaults to "postgres")' },
4756
- dbUser: { type: "string", description: 'User (defaults to "postgres")' },
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.2.0";
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: use these directly with \`db-query containerName=\u2026 dbName=\u2026 dbUser=\u2026\` or \`db-apply-migration\`.`);
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
- const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "postgres";
5855
- const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName : "postgres";
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
- const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName.replace(/[^a-zA-Z0-9_-]/g, "") : "";
5952
- const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") : "";
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
- const dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
5992
- const dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
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
- const dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
6172
- const dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
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