@mgsoftwarebv/mg-dashboard-mcp 6.1.1 → 6.3.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
@@ -1191,7 +1191,7 @@ async function handleVercelTool(name, args2, deps) {
1191
1191
  sinceMs
1192
1192
  );
1193
1193
  if (error) {
1194
- const hint = error.includes("404") || error.includes("400") ? '\n\nThis endpoint requires both project ID and deployment ID and may not be available on every Vercel plan. Use kind="webhooks" or the supabase MCP (vercel_deployment_log table) for archived runtime logs.' : "";
1194
+ const hint = error.includes("404") || error.includes("400") ? '\n\nThis endpoint requires both project ID and deployment ID and may not be available on every Vercel plan. Use kind="webhooks" or query the vercel_deployment_log table directly for archived runtime logs.' : "";
1195
1195
  return { content: [{ type: "text", text: `Error: ${error}${hint}` }] };
1196
1196
  }
1197
1197
  const body = formatRuntimeLogs(logs);
@@ -1199,7 +1199,7 @@ async function handleVercelTool(name, args2, deps) {
1199
1199
  const footer = hitDurationLimit ? `
1200
1200
 
1201
1201
  [${windowNote}]
1202
- [hint] Vercel hit its 5-min query budget for this window. Try a smaller sinceMinutes (e.g. 5-10), lower limit, or use kind="webhooks" / the supabase MCP vercel_deployment_log table for archived logs.` : `
1202
+ [hint] Vercel hit its 5-min query budget for this window. Try a smaller sinceMinutes (e.g. 5-10), lower limit, or use kind="webhooks" / query the vercel_deployment_log table for archived logs.` : `
1203
1203
 
1204
1204
  [${windowNote}]`;
1205
1205
  return { content: [{ type: "text", text: body + footer }] };
@@ -2420,6 +2420,10 @@ function assertServerAccess(serverId) {
2420
2420
  throw new Error(`Access denied: you do not have permission for server ${serverId}`);
2421
2421
  }
2422
2422
  }
2423
+ function uuidArrayParam(values) {
2424
+ if (values.length === 0) return sql`ARRAY[]::uuid[]`;
2425
+ return sql`ARRAY[${sql.join(values.map((v) => sql`${v}::uuid`), sql`, `)}]`;
2426
+ }
2423
2427
  async function resolveReleaseProfileStageIds(profileName) {
2424
2428
  const profileRows = await db.execute(sql`
2425
2429
  SELECT id, name FROM release_profile
@@ -2449,13 +2453,13 @@ async function getProfileNamesForStageIds(stageIds) {
2449
2453
  if (stageIds.length === 0) return {};
2450
2454
  const stages = await db.execute(sql`
2451
2455
  SELECT id, release_profile_id FROM release_profile_stage
2452
- WHERE id = ANY(${stageIds}::uuid[])
2456
+ WHERE id = ANY(${uuidArrayParam(stageIds)})
2453
2457
  `);
2454
2458
  if (stages.length === 0) return {};
2455
2459
  const profileIds = [...new Set(stages.map((s) => s.release_profile_id))];
2456
2460
  const profiles = await db.execute(sql`
2457
2461
  SELECT id, name FROM release_profile
2458
- WHERE id = ANY(${profileIds}::uuid[])
2462
+ WHERE id = ANY(${uuidArrayParam(profileIds)})
2459
2463
  `);
2460
2464
  const profileMap = {};
2461
2465
  for (const p of profiles) profileMap[p.id] = p.name;
@@ -2690,7 +2694,35 @@ async function getProxyConnection() {
2690
2694
  };
2691
2695
  return _proxyConnCache;
2692
2696
  }
2693
- async function getServerConnection(serverId) {
2697
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2698
+ async function resolveServerId(input) {
2699
+ const trimmed = (input ?? "").trim();
2700
+ if (!trimmed) throw new Error("serverId is required");
2701
+ if (UUID_RE.test(trimmed)) return trimmed;
2702
+ const exact = await db.execute(sql`
2703
+ SELECT id, name FROM ssh_server WHERE name = ${trimmed} LIMIT 2
2704
+ `);
2705
+ if (exact.length === 1) {
2706
+ KNOWN_SERVER_NAMES.set(exact[0].id, { id: exact[0].id, name: exact[0].name });
2707
+ return exact[0].id;
2708
+ }
2709
+ if (exact.length > 1) {
2710
+ throw new Error(`Multiple servers share the exact name "${trimmed}". Pass the UUID instead.`);
2711
+ }
2712
+ const fuzzy = await db.execute(sql`
2713
+ SELECT id, name FROM ssh_server WHERE name ILIKE ${"%" + trimmed + "%"} ORDER BY name LIMIT 5
2714
+ `);
2715
+ if (fuzzy.length === 1) {
2716
+ KNOWN_SERVER_NAMES.set(fuzzy[0].id, { id: fuzzy[0].id, name: fuzzy[0].name });
2717
+ return fuzzy[0].id;
2718
+ }
2719
+ if (fuzzy.length === 0) {
2720
+ throw new Error(`Server "${trimmed}" not found by id or name. Call \`list-servers\` to see what's available.`);
2721
+ }
2722
+ throw new Error(`Multiple servers match "${trimmed}": ${fuzzy.map((f) => f.name).join(", ")}. Use an exact name or UUID.`);
2723
+ }
2724
+ async function getServerConnection(serverIdOrName) {
2725
+ const serverId = await resolveServerId(serverIdOrName);
2694
2726
  assertServerAccess(serverId);
2695
2727
  const rows = await db.execute(sql`
2696
2728
  SELECT hostname, port, username, password_encrypted, ssh_key_encrypted,
@@ -2718,7 +2750,7 @@ async function getServerConnection(serverId) {
2718
2750
  const needsProxy = data.allowed_ssh_ips !== null && serverId !== SSH_PROXY_SERVER_ID;
2719
2751
  const proxy = needsProxy ? await getProxyConnection() : void 0;
2720
2752
  const os = data.os_type === "windows" ? "windows" : "linux";
2721
- return { conn, proxy, os };
2753
+ return { serverId, conn, proxy, os };
2722
2754
  }
2723
2755
  async function sshExec(opts, command, proxy, options) {
2724
2756
  const first = await sshExecOnce(opts, command, proxy, options);
@@ -4200,6 +4232,34 @@ done
4200
4232
  }
4201
4233
  return out;
4202
4234
  }
4235
+ var POSTGRES_CONTAINER_CREDS_CACHE = /* @__PURE__ */ new Map();
4236
+ var PG_CREDS_TTL_MS = 60 * 60 * 1e3;
4237
+ function cachePostgresContainerCreds(serverId, containerName, creds) {
4238
+ POSTGRES_CONTAINER_CREDS_CACHE.set(`${serverId}|${containerName}`, { ...creds, capturedAt: Date.now() });
4239
+ }
4240
+ async function resolvePostgresContainerCreds(conn, proxy, serverId, containerName) {
4241
+ const key = `${serverId}|${containerName}`;
4242
+ const cached = POSTGRES_CONTAINER_CREDS_CACHE.get(key);
4243
+ if (cached && Date.now() - cached.capturedAt < PG_CREDS_TTL_MS) return cached;
4244
+ const safeContainer = containerName.replace(/[^a-zA-Z0-9._-]/g, "");
4245
+ const res = await sshExec(
4246
+ conn,
4247
+ `docker exec ${safeContainer} env 2>/dev/null | grep -E '^POSTGRES_(USER|DB|PASSWORD)='`,
4248
+ proxy
4249
+ );
4250
+ let dbUser = "postgres";
4251
+ let dbName = "postgres";
4252
+ let hasPassword = false;
4253
+ for (const raw of res.stdout.split("\n")) {
4254
+ const line = raw.trim();
4255
+ if (line.startsWith("POSTGRES_USER=")) dbUser = line.slice("POSTGRES_USER=".length) || "postgres";
4256
+ else if (line.startsWith("POSTGRES_DB=")) dbName = line.slice("POSTGRES_DB=".length) || "postgres";
4257
+ else if (line.startsWith("POSTGRES_PASSWORD=")) hasPassword = true;
4258
+ }
4259
+ const creds = { dbName, dbUser, hasPassword, capturedAt: Date.now() };
4260
+ POSTGRES_CONTAINER_CREDS_CACHE.set(key, creds);
4261
+ return creds;
4262
+ }
4203
4263
  async function psqlInContainer(conn, proxy, containerName, dbUser, dbName, scriptSql, flags) {
4204
4264
  const safeContainer = containerName.replace(/[^a-zA-Z0-9._-]/g, "");
4205
4265
  const safeUser = dbUser.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
@@ -4442,7 +4502,7 @@ async function mijnhostFetch(path, options = {}) {
4442
4502
  var TOOLS = [
4443
4503
  {
4444
4504
  name: "list-servers",
4445
- description: 'List all SSH servers you have access to. Returns id, name, hostname, tags, and os_type per server. Pass `includeStats: true` to also probe each server in parallel for container count, disk-free, and uptime (skips unreachable hosts gracefully). Cached for 60s \u2014 pass `noCache: true` to force a refresh. Adds an "auto-context" footer with one-line tags inferred from server name/tags so the LLM knows which host runs Trigger / Supabase / proxy / etc. (disable with `context: false`).',
4505
+ description: 'List all SSH servers you have access to. Returns id, name, hostname, tags, and os_type per server. Pass `includeStats: true` to also probe each server in parallel for container count, disk-free, and uptime (skips unreachable hosts gracefully). Cached for 60s \u2014 pass `noCache: true` to force a refresh. Adds an "auto-context" footer with one-line tags inferred from server name/tags so the LLM knows which host runs Trigger / Supabase / proxy / etc. (disable with `context: false`).\n\n**Tip:** every tool that takes `serverId` also accepts the server `name` directly (exact match or fuzzy `ILIKE %x%`). E.g. `ssh-execute serverId="MG Giga Server"` works without a separate UUID lookup.',
4446
4506
  inputSchema: {
4447
4507
  type: "object",
4448
4508
  properties: {
@@ -4655,22 +4715,22 @@ var TOOLS = [
4655
4715
  },
4656
4716
  {
4657
4717
  name: "db-tables",
4658
- 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.",
4718
+ 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.",
4659
4719
  inputSchema: {
4660
4720
  type: "object",
4661
4721
  properties: {
4662
- serverId: { type: "string", description: "UUID of the SSH server" },
4722
+ serverId: { type: "string", description: "UUID or (fuzzy) name of the SSH server" },
4663
4723
  sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). MySQL autodiscover mode." },
4664
4724
  containerName: { type: "string", description: "Postgres container name. Activates direct Postgres mode." },
4665
- dbName: { type: "string", description: 'Database name (containerName mode, defaults to "postgres")' },
4666
- dbUser: { type: "string", description: 'Database user (containerName mode, defaults to "postgres")' }
4725
+ dbName: { type: "string", description: "Database name (containerName mode). Auto-resolved from container env when omitted." },
4726
+ dbUser: { type: "string", description: "Database user (containerName mode). Auto-resolved from container env when omitted." }
4667
4727
  },
4668
4728
  required: ["serverId"]
4669
4729
  }
4670
4730
  },
4671
4731
  {
4672
4732
  name: "db-query",
4673
- 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\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.',
4733
+ 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.',
4674
4734
  inputSchema: {
4675
4735
  type: "object",
4676
4736
  properties: {
@@ -4680,46 +4740,48 @@ var TOOLS = [
4680
4740
  sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
4681
4741
  containerName: { type: "string", description: 'Docker container running the DB server (e.g. "refront-postgres-vanilla", "supabase-db"). Activates direct-query mode.' },
4682
4742
  engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
4683
- dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
4684
- dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
4743
+ 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." },
4744
+ 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".' },
4685
4745
  dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." },
4686
4746
  maxRows: { type: "number", description: "Auto-inject LIMIT for unbounded SELECTs. Default 1000, max 10000, set 0 to disable." },
4687
4747
  explain: { type: "boolean", description: "Prepend EXPLAIN (postgres/mysql) or SHOWPLAN (mssql) instead of executing. Useful for slow-query diagnosis." },
4688
- allowDestructive: { type: "string", description: 'Escape hatch for ad-hoc DROP/TRUNCATE/ALTER\u2026DROP/naked DELETE. Pass the literal string "yes-i-understand-this-is-not-logged". For schema migrations prefer db-apply-migration (audit-logged, idempotent).' }
4748
+ allowDestructive: { type: "string", description: 'Escape hatch for ad-hoc DROP/TRUNCATE/ALTER\u2026DROP/naked DELETE. Pass the literal string "yes-i-understand-this-is-not-logged". For schema migrations prefer db-apply-migration (audit-logged, idempotent).' },
4749
+ transaction: { type: "boolean", description: "Wrap the query in BEGIN/COMMIT so multiple statements either all succeed or all roll back. Use for atomic ad-hoc updates like `UPDATE x SET y = y * 100; UPDATE z ...` that are not migration-worthy. Ignored in EXPLAIN mode and on MSSQL (use its own transaction syntax). Postgres/MySQL only." }
4689
4750
  },
4690
4751
  required: ["serverId"]
4691
4752
  }
4692
4753
  },
4693
4754
  {
4694
4755
  name: "db-apply-migration",
4695
- 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" }`.',
4756
+ 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).',
4696
4757
  inputSchema: {
4697
4758
  type: "object",
4698
4759
  properties: {
4699
- serverId: { type: "string", description: "UUID of the SSH server" },
4760
+ serverId: { type: "string", description: "UUID or (fuzzy) name of the SSH server" },
4700
4761
  containerName: { type: "string", description: 'Postgres container name (e.g. "refront-postgres-vanilla")' },
4701
- dbName: { type: "string", description: 'Database (defaults to "postgres" \u2014 set explicitly to your app DB)' },
4702
- dbUser: { type: "string", description: 'User (defaults to "postgres")' },
4762
+ dbName: { type: "string", description: "Database. Auto-resolved from container `POSTGRES_DB` env when omitted. Override only when the instance hosts multiple databases." },
4763
+ dbUser: { type: "string", description: "User. Auto-resolved from container `POSTGRES_USER` env when omitted." },
4703
4764
  name: { type: "string", description: "Unique migration name (recommended format: `YYYYMMDDhhmmss_description`)" },
4704
4765
  sql: { type: "string", description: "Inline SQL string (mutually exclusive with localFile / remoteFile)" },
4705
4766
  localFile: { type: "string", description: "Path on this machine to a .sql file (read here, streamed via stdin)" },
4706
4767
  remoteFile: { type: "string", description: "Absolute path of a .sql file already on the server (read with `cat`)" },
4707
4768
  noTransaction: { type: "boolean", description: "Skip the implicit BEGIN/COMMIT wrapper. Required for CONCURRENTLY / ALTER TYPE ADD VALUE / VACUUM. Default false." },
4708
- force: { type: "boolean", description: "Re-apply even when the ledger says it ran with a different sha256. Use after a deliberate edit; logged in the response." }
4769
+ force: { type: "boolean", description: "Re-apply even when the ledger says it ran with a different sha256. Use after a deliberate edit; logged in the response." },
4770
+ recordOnly: { type: "boolean", description: "Register the migration in the ledger WITHOUT executing the SQL. Use to backfill migrations that were applied out-of-band (e.g. by hand via ssh + psql). The SQL is still required so the recorded sha256 matches what was run." }
4709
4771
  },
4710
4772
  required: ["serverId", "containerName", "name"]
4711
4773
  }
4712
4774
  },
4713
4775
  {
4714
4776
  name: "db-list-migrations",
4715
- 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.',
4777
+ 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.',
4716
4778
  inputSchema: {
4717
4779
  type: "object",
4718
4780
  properties: {
4719
- serverId: { type: "string", description: "UUID of the SSH server" },
4781
+ serverId: { type: "string", description: "UUID or (fuzzy) name of the SSH server" },
4720
4782
  containerName: { type: "string", description: "Postgres container name" },
4721
- dbName: { type: "string", description: 'Database (defaults to "postgres")' },
4722
- dbUser: { type: "string", description: 'User (defaults to "postgres")' },
4783
+ dbName: { type: "string", description: "Database. Auto-resolved from container `POSTGRES_DB` env when omitted." },
4784
+ dbUser: { type: "string", description: "User. Auto-resolved from container `POSTGRES_USER` env when omitted." },
4723
4785
  limit: { type: "number", description: "Max number of entries (default 50, max 500)" }
4724
4786
  },
4725
4787
  required: ["serverId", "containerName"]
@@ -4824,7 +4886,7 @@ var TOOLS = [
4824
4886
  // ----- Repo reference -----
4825
4887
  ...REPO_TOOLS
4826
4888
  ];
4827
- var MCP_VERSION = "6.1.1";
4889
+ var MCP_VERSION = "6.3.0";
4828
4890
  async function handleListTools() {
4829
4891
  if (!authContext) return { tools: TOOLS };
4830
4892
  const accessible = TOOLS.filter((tool) => {
@@ -4892,7 +4954,7 @@ async function executeToolCall(name, a, _serverId) {
4892
4954
  const data = ctx.allowedServerIds !== null ? await db.execute(sql`
4893
4955
  SELECT id, name, hostname, port, username, tags, hosted_by, os_type, created_at
4894
4956
  FROM ssh_server
4895
- WHERE id = ANY(${ctx.allowedServerIds}::uuid[])
4957
+ WHERE id = ANY(${uuidArrayParam(ctx.allowedServerIds)})
4896
4958
  ORDER BY name
4897
4959
  `) : await db.execute(sql`
4898
4960
  SELECT id, name, hostname, port, username, tags, hosted_by, os_type, created_at
@@ -5780,7 +5842,7 @@ ${trail.join("\n")}` }] };
5780
5842
  }
5781
5843
  // ----- Database -----
5782
5844
  case "db-discover": {
5783
- const { conn, proxy } = await getServerConnection(String(a.serverId));
5845
+ const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
5784
5846
  const rawInclude = Array.isArray(a.include) ? a.include.map(String) : ["www"];
5785
5847
  const include = new Set(rawInclude.filter((m) => m === "www" || m === "postgres-containers"));
5786
5848
  if (include.size === 0) include.add("www");
@@ -5799,6 +5861,13 @@ ${lines.join("\n")}`);
5799
5861
  }
5800
5862
  if (include.has("postgres-containers")) {
5801
5863
  const containers = await discoverPostgresContainers(conn, proxy);
5864
+ for (const c of containers) {
5865
+ cachePostgresContainerCreds(serverId, c.container, {
5866
+ dbName: c.db,
5867
+ dbUser: c.user,
5868
+ hasPassword: c.hasPassword
5869
+ });
5870
+ }
5802
5871
  if (!containers.length) {
5803
5872
  sections.push("## Postgres containers\n(none \u2014 no running container exposes POSTGRES_USER/POSTGRES_DB)");
5804
5873
  } else {
@@ -5808,17 +5877,22 @@ ${lines.join("\n")}`);
5808
5877
  sections.push(`## Postgres containers (${containers.length})
5809
5878
  ${lines.join("\n")}
5810
5879
 
5811
- Tip: use these directly with \`db-query containerName=\u2026 dbName=\u2026 dbUser=\u2026\` or \`db-apply-migration\`.`);
5880
+ 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).`);
5812
5881
  }
5813
5882
  }
5814
5883
  return { content: [{ type: "text", text: sections.join("\n\n") }] };
5815
5884
  }
5816
5885
  case "db-tables": {
5817
- const { conn, proxy } = await getServerConnection(String(a.serverId));
5886
+ const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
5818
5887
  const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName : "";
5819
5888
  if (containerName) {
5820
- const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "postgres";
5821
- const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName : "postgres";
5889
+ let dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "";
5890
+ let dbName = typeof a.dbName === "string" && a.dbName ? a.dbName : "";
5891
+ if (!dbUser || !dbName) {
5892
+ const creds = await resolvePostgresContainerCreds(conn, proxy, serverId, containerName);
5893
+ if (!dbUser) dbUser = creds.dbUser;
5894
+ if (!dbName) dbName = creds.dbName;
5895
+ }
5822
5896
  const sqlText2 = `SELECT n.nspname AS schema,
5823
5897
  c.relname AS "table",
5824
5898
  pg_size_pretty(pg_total_relation_size(c.oid)) AS size,
@@ -5878,6 +5952,7 @@ For a one-off ad-hoc destructive query, pass \`allowDestructive: "${allowDestruc
5878
5952
  }
5879
5953
  let query = rawQuery.replace(/;\s*$/, "");
5880
5954
  let appliedLimit = false;
5955
+ const wrapTransaction = a.transaction === true && !explainMode && !describeArg && engine !== "mssql";
5881
5956
  if (explainMode && !describeArg) {
5882
5957
  if (engine === "mssql") {
5883
5958
  query = `SET SHOWPLAN_TEXT ON;
@@ -5894,20 +5969,33 @@ LIMIT ${maxRows + 1}`;
5894
5969
  appliedLimit = true;
5895
5970
  }
5896
5971
  }
5972
+ if (wrapTransaction) {
5973
+ query = `BEGIN;
5974
+ ${query.replace(/;\s*$/, "")};
5975
+ COMMIT;`;
5976
+ }
5977
+ const txBanner = wrapTransaction ? `
5978
+
5979
+ [transaction] wrapped in BEGIN/COMMIT \u2014 all statements committed atomically.` : "";
5897
5980
  const destructiveBanner = allowDestructive ? "\n\n[allowDestructive] safety gate bypassed for this query \u2014 NOT recorded in any audit ledger. Consider db-apply-migration for repeatable schema changes." : "";
5898
5981
  if (!containerName && engine === "mysql") {
5899
5982
  if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
5900
5983
  const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
5901
5984
  const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
5902
5985
  const footer2 = formatDbQueryFooter(output2, appliedLimit, maxRows, explainMode);
5903
- return { content: [{ type: "text", text: (output2 || "Query executed successfully (no output)") + footer2 + destructiveBanner }] };
5986
+ return { content: [{ type: "text", text: (output2 || "Query executed successfully (no output)") + footer2 + destructiveBanner + txBanner }] };
5904
5987
  }
5905
5988
  if (!containerName) {
5906
5989
  return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
5907
5990
  }
5908
- const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName.replace(/[^a-zA-Z0-9_-]/g, "") : "";
5909
- const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") : "";
5910
- const { conn, proxy } = await getServerConnection(String(a.serverId));
5991
+ let dbName = typeof a.dbName === "string" && a.dbName ? a.dbName.replace(/[^a-zA-Z0-9_-]/g, "") : "";
5992
+ let dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") : "";
5993
+ const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
5994
+ if (engine === "postgres" && (!dbName || !dbUser)) {
5995
+ const creds = await resolvePostgresContainerCreds(conn, proxy, serverId, containerName);
5996
+ if (!dbName) dbName = creds.dbName.replace(/[^a-zA-Z0-9_-]/g, "");
5997
+ if (!dbUser) dbUser = creds.dbUser.replace(/[^a-zA-Z0-9_-]/g, "");
5998
+ }
5911
5999
  let cmd;
5912
6000
  let stdinPayload;
5913
6001
  if (engine === "postgres") {
@@ -5940,13 +6028,13 @@ GO
5940
6028
  return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
5941
6029
  }
5942
6030
  const footer = formatDbQueryFooter(output, appliedLimit, maxRows, explainMode);
5943
- return { content: [{ type: "text", text: output + footer + destructiveBanner }] };
6031
+ return { content: [{ type: "text", text: output + footer + destructiveBanner + txBanner }] };
5944
6032
  }
5945
6033
  case "db-apply-migration": {
5946
6034
  const containerName = String(a.containerName || "").replace(/[^a-zA-Z0-9._-]/g, "");
5947
6035
  if (!containerName) return { content: [{ type: "text", text: "Error: containerName is required" }] };
5948
- const dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
5949
- const dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
6036
+ let dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "").replace(/[^a-zA-Z0-9_-]/g, "");
6037
+ let dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "").replace(/[^a-zA-Z0-9_-]/g, "");
5950
6038
  const name2 = String(a.name || "").trim();
5951
6039
  if (!name2) return { content: [{ type: "text", text: 'Error: name is required (e.g. "20260517120000_add_foo")' }] };
5952
6040
  if (!/^[\w.@:+\-]+$/.test(name2) || name2.length > 200) {
@@ -5954,6 +6042,9 @@ GO
5954
6042
  }
5955
6043
  const noTransaction = a.noTransaction === true;
5956
6044
  const force = a.force === true;
6045
+ const recordOnly = a.recordOnly === true;
6046
+ const t0 = Date.now();
6047
+ const took = () => `${((Date.now() - t0) / 1e3).toFixed(2)}s`;
5957
6048
  const sources = [
5958
6049
  typeof a.sql === "string" && a.sql ? "sql" : null,
5959
6050
  typeof a.localFile === "string" && a.localFile ? "localFile" : null,
@@ -5962,7 +6053,12 @@ GO
5962
6053
  if (sources.length !== 1) {
5963
6054
  return { content: [{ type: "text", text: `Error: pass exactly one of \`sql\`, \`localFile\`, or \`remoteFile\` (got ${sources.length || "none"}: ${sources.join(", ") || "\u2014"})` }] };
5964
6055
  }
5965
- const { conn, proxy } = await getServerConnection(String(a.serverId));
6056
+ const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
6057
+ if (!dbUser || !dbName) {
6058
+ const creds = await resolvePostgresContainerCreds(conn, proxy, serverId, containerName);
6059
+ if (!dbUser) dbUser = creds.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
6060
+ if (!dbName) dbName = creds.dbName.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
6061
+ }
5966
6062
  let migrationSql;
5967
6063
  if (typeof a.sql === "string" && a.sql) {
5968
6064
  migrationSql = a.sql;
@@ -5992,20 +6088,23 @@ GO
5992
6088
  return { content: [{ type: "text", text: "Error: resolved SQL is empty" }] };
5993
6089
  }
5994
6090
  const sha = migrationSha256(migrationSql);
5995
- const probeSql = `${MIGRATION_LEDGER_DDL}
5996
- SELECT sha256 FROM _mcp_migrations WHERE name = ${dollarQuote(name2)};
6091
+ const ddl = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, `${MIGRATION_LEDGER_DDL}
6092
+ `, "");
6093
+ if (ddl.exitCode !== 0) {
6094
+ return { content: [{ type: "text", text: `Error initialising ledger on ${containerName} (${dbName}): ${ddl.stderr || ddl.stdout || `exit ${ddl.exitCode}`}` }] };
6095
+ }
6096
+ const probeSelect = `SELECT sha256 FROM _mcp_migrations WHERE name = ${dollarQuote(name2)};
5997
6097
  `;
5998
- const probe = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, probeSql, "-tA");
6098
+ const probe = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, probeSelect, "-tA");
5999
6099
  if (probe.exitCode !== 0) {
6000
- return { content: [{ type: "text", text: `Error initialising ledger on ${containerName} (${dbName}): ${probe.stderr || probe.stdout || `exit ${probe.exitCode}`}` }] };
6100
+ return { content: [{ type: "text", text: `Error reading ledger on ${containerName} (${dbName}): ${probe.stderr || probe.stdout || `exit ${probe.exitCode}`}` }] };
6001
6101
  }
6002
- const probeLines = probe.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
6003
- const existingSha = probeLines.length > 0 ? probeLines[probeLines.length - 1] : "";
6102
+ const existingSha = probe.stdout.split("\n").map((l) => l.trim()).find((l) => /^[0-9a-f]{64}$/.test(l)) || "";
6004
6103
  if (existingSha && existingSha === sha) {
6005
6104
  return {
6006
6105
  content: [{
6007
6106
  type: "text",
6008
- text: `[noop] Migration "${name2}" already applied on ${containerName}/${dbName} (sha256 matches).
6107
+ text: `[noop] Migration "${name2}" already applied on ${containerName}/${dbName} (sha256 matches, took ${took()}).
6009
6108
  Ledger sha: ${sha.slice(0, 12)}\u2026`
6010
6109
  }]
6011
6110
  };
@@ -6014,7 +6113,7 @@ Ledger sha: ${sha.slice(0, 12)}\u2026`
6014
6113
  return {
6015
6114
  content: [{
6016
6115
  type: "text",
6017
- text: `[drift] Migration "${name2}" exists in ledger with DIFFERENT sha256.
6116
+ text: `[drift] Migration "${name2}" exists in ledger with DIFFERENT sha256 (took ${took()}).
6018
6117
  ledger sha: ${existingSha.slice(0, 12)}\u2026
6019
6118
  new sha: ${sha.slice(0, 12)}\u2026
6020
6119
 
@@ -6026,6 +6125,29 @@ This usually means the migration file was edited after it was first applied. Ins
6026
6125
  const ledgerInsert = `INSERT INTO _mcp_migrations (name, sha256, applied_by) VALUES (${dollarQuote(name2)}, ${dollarQuote(sha)}, ${dollarQuote(appliedBy)})
6027
6126
  ON CONFLICT (name) DO UPDATE SET sha256 = EXCLUDED.sha256, applied_at = now(), applied_by = EXCLUDED.applied_by;
6028
6127
  `;
6128
+ if (recordOnly) {
6129
+ const record = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, ledgerInsert, "");
6130
+ if (record.exitCode !== 0) {
6131
+ return {
6132
+ content: [{
6133
+ type: "text",
6134
+ text: `[failed] Ledger insert for "${name2}" failed (recordOnly=true, no SQL was executed).
6135
+ psql exit: ${record.exitCode}
6136
+ ` + (record.stderr ? `--- stderr ---
6137
+ ${record.stderr}
6138
+ ` : "") + (record.stdout ? `--- stdout ---
6139
+ ${record.stdout}` : "")
6140
+ }]
6141
+ };
6142
+ }
6143
+ return {
6144
+ content: [{
6145
+ type: "text",
6146
+ text: `[recorded] "${name2}" written to ledger on ${containerName}/${dbName} WITHOUT executing the SQL (recordOnly=true${existingSha ? ", overwrote existing entry" : ""}, took ${took()}).
6147
+ ledger sha: ${sha.slice(0, 12)}\u2026`
6148
+ }]
6149
+ };
6150
+ }
6029
6151
  if (noTransaction) {
6030
6152
  const apply = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, normalised, "");
6031
6153
  if (apply.exitCode !== 0) {
@@ -6058,7 +6180,7 @@ ${record.stdout}` : "")
6058
6180
  return {
6059
6181
  content: [{
6060
6182
  type: "text",
6061
- text: `[applied] "${name2}" on ${containerName}/${dbName} (noTransaction=true${existingSha ? ", force" : ""}).
6183
+ text: `[applied] "${name2}" on ${containerName}/${dbName} (noTransaction=true${existingSha ? ", force" : ""}, took ${took()}).
6062
6184
  ledger sha: ${sha.slice(0, 12)}\u2026
6063
6185
  ` + (apply.stdout ? `--- output ---
6064
6186
  ${apply.stdout.trim()}` : "(no output)")
@@ -6086,7 +6208,7 @@ ${res.stdout}` : "")
6086
6208
  return {
6087
6209
  content: [{
6088
6210
  type: "text",
6089
- text: `[applied] "${name2}" on ${containerName}/${dbName}${existingSha ? " (forced overwrite)" : ""}.
6211
+ text: `[applied] "${name2}" on ${containerName}/${dbName}${existingSha ? " (forced overwrite)" : ""} (took ${took()}).
6090
6212
  ledger sha: ${sha.slice(0, 12)}\u2026
6091
6213
  ` + (res.stdout ? `--- output ---
6092
6214
  ${res.stdout.trim()}` : "(no output)")
@@ -6096,13 +6218,17 @@ ${res.stdout.trim()}` : "(no output)")
6096
6218
  case "db-list-migrations": {
6097
6219
  const containerName = String(a.containerName || "").replace(/[^a-zA-Z0-9._-]/g, "");
6098
6220
  if (!containerName) return { content: [{ type: "text", text: "Error: containerName is required" }] };
6099
- const dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
6100
- const dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
6221
+ let dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "").replace(/[^a-zA-Z0-9_-]/g, "");
6222
+ let dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "").replace(/[^a-zA-Z0-9_-]/g, "");
6101
6223
  const limitRaw = Number(a.limit);
6102
6224
  const limit = Math.min(Math.max(Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 50, 1), 500);
6103
- const { conn, proxy } = await getServerConnection(String(a.serverId));
6104
- const sqlText = `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = '_mcp_migrations') THEN RAISE NOTICE 'NO_LEDGER'; END IF; END $$;
6105
- SELECT name, substr(sha256, 1, 12) || '\u2026' AS sha, applied_at, applied_by
6225
+ const { serverId, conn, proxy } = await getServerConnection(String(a.serverId));
6226
+ if (!dbUser || !dbName) {
6227
+ const creds = await resolvePostgresContainerCreds(conn, proxy, serverId, containerName);
6228
+ if (!dbUser) dbUser = creds.dbUser.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
6229
+ if (!dbName) dbName = creds.dbName.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
6230
+ }
6231
+ const sqlText = `SELECT name, sha256, applied_at, applied_by
6106
6232
  FROM _mcp_migrations
6107
6233
  ORDER BY applied_at DESC
6108
6234
  LIMIT ${limit};
@@ -6127,7 +6253,7 @@ LIMIT ${limit};
6127
6253
  const data = stageFilterIds ? await db.execute(sql`
6128
6254
  SELECT id, app_name, environment, description, updated_at, release_profile_stage_id
6129
6255
  FROM env_config
6130
- WHERE release_profile_stage_id = ANY(${stageFilterIds}::uuid[])
6256
+ WHERE release_profile_stage_id = ANY(${uuidArrayParam(stageFilterIds)})
6131
6257
  ORDER BY app_name, environment
6132
6258
  `) : await db.execute(sql`
6133
6259
  SELECT id, app_name, environment, description, updated_at, release_profile_stage_id
@@ -6155,7 +6281,7 @@ LIMIT ${limit};
6155
6281
  FROM env_config
6156
6282
  WHERE app_name = ${appName}
6157
6283
  AND environment = ${environment}
6158
- AND release_profile_stage_id = ANY(${stageFilterIds}::uuid[])
6284
+ AND release_profile_stage_id = ANY(${uuidArrayParam(stageFilterIds)})
6159
6285
  `) : await db.execute(sql`
6160
6286
  SELECT env_data_encrypted, release_profile_stage_id
6161
6287
  FROM env_config
@@ -6190,7 +6316,7 @@ LIMIT ${limit};
6190
6316
  FROM env_config
6191
6317
  WHERE app_name = ${appName}
6192
6318
  AND environment = ${environment}
6193
- AND release_profile_stage_id = ANY(${resolvedStageIds}::uuid[])
6319
+ AND release_profile_stage_id = ANY(${uuidArrayParam(resolvedStageIds)})
6194
6320
  `) : await db.execute(sql`
6195
6321
  SELECT id, release_profile_stage_id
6196
6322
  FROM env_config