@mgsoftwarebv/mg-dashboard-mcp 6.0.2 → 6.1.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
@@ -10,7 +10,9 @@ import { ListToolsRequestSchema, CallToolRequestSchema, isInitializeRequest, Lis
10
10
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
11
11
  import { createServer } from 'http';
12
12
  import { randomUUID, createHash, randomBytes, createDecipheriv, createCipheriv } from 'crypto';
13
- import { createClient } from '@supabase/supabase-js';
13
+ import { sql } from 'drizzle-orm';
14
+ import { drizzle } from 'drizzle-orm/postgres-js';
15
+ import postgres from 'postgres';
14
16
  import { readFile, mkdtemp, writeFile, rm } from 'fs/promises';
15
17
  import { tmpdir } from 'os';
16
18
  import { Client } from 'ssh2';
@@ -270,6 +272,44 @@ var init_proxy_mode = __esm({
270
272
  "src/proxy-mode.ts"() {
271
273
  }
272
274
  });
275
+ var connectionConfig = {
276
+ prepare: false,
277
+ idle_timeout: 20,
278
+ max_lifetime: 60 * 60,
279
+ connect_timeout: 30,
280
+ max: 10,
281
+ onnotice: (notice) => {
282
+ if (notice.severity !== "INFO") {
283
+ console.warn("[mg-dashboard db]", notice.message);
284
+ }
285
+ },
286
+ connection: {
287
+ application_name: `mg-dashboard-${process.env.VERCEL ? "vercel" : process.env.TRIGGER_RUN_ID ? "trigger" : "local"}`
288
+ }
289
+ };
290
+ function getDatabaseUrl() {
291
+ const url = process.env.DATABASE_PRIMARY_POOLER_URL || process.env.DATABASE_PRIMARY_URL;
292
+ if (!url) {
293
+ throw new Error(
294
+ "[@mgboiler/db] DATABASE_PRIMARY_URL or DATABASE_PRIMARY_POOLER_URL must be set"
295
+ );
296
+ }
297
+ return url;
298
+ }
299
+ var _pool = null;
300
+ var _db = null;
301
+ function getPool() {
302
+ if (!_pool) {
303
+ _pool = postgres(getDatabaseUrl(), connectionConfig);
304
+ }
305
+ return _pool;
306
+ }
307
+ function getDb() {
308
+ if (!_db) {
309
+ _db = drizzle(getPool(), { casing: "snake_case" });
310
+ }
311
+ return _db;
312
+ }
273
313
 
274
314
  // src/trigger-tools.ts
275
315
  var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
@@ -340,10 +380,10 @@ var TRIGGER_TOOL_MODULE_MAP = {
340
380
  "trigger-run": "ci_cd"
341
381
  };
342
382
  async function discoverInstance(projectSlug, conn, proxy, sshExec2) {
343
- const sql = `SELECT re.\\"apiKey\\" FROM \\"RuntimeEnvironment\\" re JOIN \\"Project\\" p ON re.\\"projectId\\" = p.id WHERE p.slug='${projectSlug}' AND re.slug='prod' LIMIT 1`;
383
+ const sql4 = `SELECT re.\\"apiKey\\" FROM \\"RuntimeEnvironment\\" re JOIN \\"Project\\" p ON re.\\"projectId\\" = p.id WHERE p.slug='${projectSlug}' AND re.slug='prod' LIMIT 1`;
344
384
  const cmd = [
345
385
  `PORT=$(docker port "${WA_CONTAINER}" 3000/tcp 2>/dev/null | head -1 | sed 's/.*://')`,
346
- `KEY=$(docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql}" 2>/dev/null | tr -d '[:space:]')`,
386
+ `KEY=$(docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql4}" 2>/dev/null | tr -d '[:space:]')`,
347
387
  'echo "$PORT|$KEY"'
348
388
  ].join(" && ");
349
389
  const result = await sshExec2(conn, cmd, proxy);
@@ -364,8 +404,8 @@ async function discoverInstance(projectSlug, conn, proxy, sshExec2) {
364
404
  return { port, apiKey: apiKey2 };
365
405
  }
366
406
  async function fetchRunLogs(runId, conn, proxy, sshExec2) {
367
- const sql = `SELECT level, message, \\"isError\\", \\"createdAt\\" FROM \\"TaskEvent\\" WHERE \\"runId\\" = '${runId}' AND level IN ('INFO','WARN','ERROR','DEBUG','LOG','TRACE') ORDER BY \\"startTime\\" ASC LIMIT 200`;
368
- const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql}" 2>/dev/null`;
407
+ const sql4 = `SELECT level, message, \\"isError\\", \\"createdAt\\" FROM \\"TaskEvent\\" WHERE \\"runId\\" = '${runId}' AND level IN ('INFO','WARN','ERROR','DEBUG','LOG','TRACE') ORDER BY \\"startTime\\" ASC LIMIT 200`;
408
+ const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql4}" 2>/dev/null`;
369
409
  const result = await sshExec2(conn, cmd, proxy);
370
410
  const output = result.stdout.trim();
371
411
  if (!output) return "";
@@ -447,8 +487,8 @@ async function handleTriggerTool(name, args2, deps) {
447
487
  switch (name) {
448
488
  // -----------------------------------------------------------------
449
489
  case "trigger-list": {
450
- const sql = 'SELECT slug, name FROM \\"Project\\" ORDER BY name';
451
- const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql}" 2>/dev/null`;
490
+ const sql4 = 'SELECT slug, name FROM \\"Project\\" ORDER BY name';
491
+ const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql4}" 2>/dev/null`;
452
492
  const result = await sshExec2(conn, cmd, proxy);
453
493
  const output = result.stdout.trim();
454
494
  if (!output) {
@@ -643,8 +683,6 @@ async function waitForCompletion(conn, proxy, sshExec2, instance, runId, waitSec
643
683
  }]
644
684
  };
645
685
  }
646
-
647
- // src/vercel-tools.ts
648
686
  var VERCEL_API = "https://api.vercel.com";
649
687
  var VERCEL_TOOLS = [
650
688
  {
@@ -934,8 +972,10 @@ async function getDomainConfig(token, domain) {
934
972
  return { config: res.data, error: null };
935
973
  }
936
974
  async function getVercelToken(deps) {
937
- const { data, error } = await deps.supabase.from("app_setting").select("vercel_token_encrypted").maybeSingle();
938
- if (error) throw new Error(`Could not read app_setting: ${error.message}`);
975
+ const rows = await deps.db.execute(sql`
976
+ SELECT vercel_token_encrypted FROM app_setting LIMIT 1
977
+ `);
978
+ const data = rows[0];
939
979
  if (!data?.vercel_token_encrypted) {
940
980
  throw new Error("Vercel API token is not configured. Add it in dashboard Settings.");
941
981
  }
@@ -1166,16 +1206,26 @@ async function handleVercelTool(name, args2, deps) {
1166
1206
  }
1167
1207
  if (kind === "webhooks") {
1168
1208
  const limit = Math.min(Math.max(Number(args2.limit) || 25, 1), 200);
1169
- let query = deps.supabase.from("vercel_webhook_logs").select(
1170
- "id, event_type, status, project_name, deployment_id, target, message, error_message, created_at"
1171
- ).order("created_at", { ascending: false }).limit(limit);
1172
- if (args2.projectName) query = query.eq("project_name", String(args2.projectName));
1173
- if (args2.status) query = query.eq("status", String(args2.status));
1174
- const { data, error } = await query;
1175
- if (error) return { content: [{ type: "text", text: `Error: ${error.message}` }] };
1176
- return {
1177
- content: [{ type: "text", text: formatWebhookHistory(data ?? []) }]
1178
- };
1209
+ const conds = [sql`TRUE`];
1210
+ if (args2.projectName) conds.push(sql`project_name = ${String(args2.projectName)}`);
1211
+ if (args2.status) conds.push(sql`status = ${String(args2.status)}`);
1212
+ const where = sql.join(conds, sql` AND `);
1213
+ try {
1214
+ const rows = await deps.db.execute(sql`
1215
+ SELECT id, event_type, status, project_name, deployment_id, target,
1216
+ message, error_message, created_at
1217
+ FROM vercel_webhook_logs
1218
+ WHERE ${where}
1219
+ ORDER BY created_at DESC
1220
+ LIMIT ${limit}
1221
+ `);
1222
+ return {
1223
+ content: [{ type: "text", text: formatWebhookHistory([...rows]) }]
1224
+ };
1225
+ } catch (err) {
1226
+ const msg = err instanceof Error ? err.message : String(err);
1227
+ return { content: [{ type: "text", text: `Error: ${msg}` }] };
1228
+ }
1179
1229
  }
1180
1230
  return {
1181
1231
  content: [
@@ -1248,6 +1298,672 @@ ${status}` }] };
1248
1298
  return { content: [{ type: "text", text: `Unknown vercel tool: ${name}` }] };
1249
1299
  }
1250
1300
  }
1301
+ var REPO_TOOLS = [
1302
+ {
1303
+ name: "repo-list",
1304
+ description: "List registered reference repositories. Auto-discovered from configured GitHub orgs; each row carries an `enabled` flag \u2014 only enabled repos are clonable on the mcp-repo-host and reachable via the other repo-* tools. Filter with `enabledOnly` (default true), `org`, `topic`, or `search` (substring on full_name / description).",
1305
+ inputSchema: {
1306
+ type: "object",
1307
+ properties: {
1308
+ enabledOnly: {
1309
+ type: "boolean",
1310
+ description: "When true (default), only return repos with enabled = true."
1311
+ },
1312
+ org: { type: "string", description: "Filter by org/owner login." },
1313
+ topic: { type: "string", description: "Match against the repo's GitHub topics array." },
1314
+ search: { type: "string", description: "Substring match (case-insensitive) on full_name or description." },
1315
+ limit: {
1316
+ type: "number",
1317
+ description: "Max rows to return (default 200, cap 1000)."
1318
+ }
1319
+ }
1320
+ }
1321
+ },
1322
+ {
1323
+ name: "repo-tree",
1324
+ description: 'List entries (files + directories) inside a repository at an optional path and ref. Uses `git ls-tree` on the central clone \u2014 no network round-trip to GitHub.\n\nIdentify the repo by `repo` (full_name like "MGSoftwareBV/mg-dashboard" or just the short name when unambiguous). `path` is the subdirectory to list (default repo root). `ref` accepts a branch, tag or SHA (default HEAD).\n\nUse `recursive: true` to walk into subdirectories; use `pattern` (glob on basename) to keep output focused (e.g. `*.tsx`). Output is bounded \u2014 pass `maxResults` to raise the cap (default 2000).',
1325
+ inputSchema: {
1326
+ type: "object",
1327
+ properties: {
1328
+ repo: { type: "string", description: 'Repo full_name (e.g. "MGSoftwareBV/mg-dashboard") or short name.' },
1329
+ path: { type: "string", description: "Subdirectory inside the repo (default: repo root)." },
1330
+ ref: { type: "string", description: "Branch, tag or commit SHA (default: HEAD aka default branch)." },
1331
+ recursive: { type: "boolean", description: "Walk into subdirectories (default false)." },
1332
+ pattern: { type: "string", description: "Glob filter on basename (e.g. `*.ts`)." },
1333
+ maxResults: { type: "number", description: "Max entries to return (default 2000, cap 20000)." }
1334
+ },
1335
+ required: ["repo"]
1336
+ }
1337
+ },
1338
+ {
1339
+ name: "repo-read",
1340
+ description: "Read a single text file from a repository at an optional ref. Uses `git show <ref>:<path>` so any branch / tag / commit works \u2014 not just the current checkout.\n\nIdentify the repo by full_name or short name. Capped at 1 MiB inline; use `offset` and/or `length` for ranged reads. Ranged reads include a `# range: bytes A-B of TOTAL` header so the caller knows what window they got.",
1341
+ inputSchema: {
1342
+ type: "object",
1343
+ properties: {
1344
+ repo: { type: "string", description: "Repo full_name or short name." },
1345
+ path: { type: "string", description: "File path inside the repo (relative to repo root)." },
1346
+ ref: { type: "string", description: "Branch, tag or commit SHA (default: HEAD)." },
1347
+ offset: { type: "number", description: "Byte offset to start reading (0-indexed). Triggers ranged-read mode." },
1348
+ length: { type: "number", description: "Bytes to read (cap 1 MiB). Defaults to remaining-from-offset." }
1349
+ },
1350
+ required: ["repo", "path"]
1351
+ }
1352
+ },
1353
+ {
1354
+ name: "repo-search",
1355
+ description: "ripgrep across one or more enabled repositories. Returns matching lines with file + line number, grouped by file.\n\nScope: pass `repo` for a single repo or `repos: [...]` for fan-out across multiple. Omit both to search every enabled repo (capped for safety).\n\n`pattern` is a regex (PCRE-lite ripgrep syntax). Toggle `caseSensitive` (default smart-case). Use `glob` to limit by filename (e.g. `*.tsx`), `path` to scope to a subdirectory, `context` for \xB1N lines, and `maxResults` to cap output (default 200 matches).",
1356
+ inputSchema: {
1357
+ type: "object",
1358
+ properties: {
1359
+ pattern: { type: "string", description: "Regex to search for (ripgrep syntax)." },
1360
+ repo: { type: "string", description: "Single repo full_name or short name." },
1361
+ repos: { type: "array", items: { type: "string" }, description: "Multiple repos (mutually exclusive with `repo`)." },
1362
+ path: { type: "string", description: "Subdirectory inside each repo to scope the search." },
1363
+ glob: { type: "string", description: "Filename glob filter (e.g. `*.ts`)." },
1364
+ caseSensitive: { type: "boolean", description: "When true, force case-sensitive (default: smart-case)." },
1365
+ context: { type: "number", description: "Show \xB1N context lines per match (default 0, cap 5)." },
1366
+ maxResults: { type: "number", description: "Cap total matches across all repos (default 200, cap 2000)." }
1367
+ },
1368
+ required: ["pattern"]
1369
+ }
1370
+ },
1371
+ {
1372
+ name: "repo-find-symbol",
1373
+ description: "Locate the definition of a symbol (class / function / type / const) across one or more repos using language-aware regex presets layered on top of ripgrep. Faster and less noisy than a plain repo-search when you know the exact identifier.\n\nPresets cover TypeScript / JavaScript, Go, Python, Rust, Java/Kotlin, C#, and PHP by default; pass `languages` to narrow (e.g. `['ts','php']`). Scope with `repo` / `repos` like repo-search.",
1374
+ inputSchema: {
1375
+ type: "object",
1376
+ properties: {
1377
+ symbol: { type: "string", description: "Exact identifier to find (no regex metacharacters)." },
1378
+ repo: { type: "string", description: "Single repo full_name or short name." },
1379
+ repos: { type: "array", items: { type: "string" }, description: "Multiple repos (mutually exclusive with `repo`)." },
1380
+ languages: {
1381
+ type: "array",
1382
+ items: { type: "string", enum: ["ts", "js", "go", "py", "rs", "java", "cs", "php"] },
1383
+ description: "Restrict to one or more language presets. Defaults to all."
1384
+ },
1385
+ maxResults: { type: "number", description: "Cap matches returned (default 50, cap 500)." }
1386
+ },
1387
+ required: ["symbol"]
1388
+ }
1389
+ },
1390
+ {
1391
+ name: "repo-log",
1392
+ description: "git log for a repository or path. Returns one row per commit: SHA, short author, ISO date, subject. Use `path` to follow history of a specific file/directory; `limit` to cap entries (default 25, max 500); optional `since` / `until` / `author` filters.",
1393
+ inputSchema: {
1394
+ type: "object",
1395
+ properties: {
1396
+ repo: { type: "string", description: "Repo full_name or short name." },
1397
+ path: { type: "string", description: "Optional path filter (file or directory)." },
1398
+ ref: { type: "string", description: "Branch / tag / SHA to start from (default HEAD)." },
1399
+ limit: { type: "number", description: "Max commits to return (default 25, max 500)." },
1400
+ since: { type: "string", description: "ISO date or git relative date (e.g. `2 weeks ago`)." },
1401
+ until: { type: "string", description: "ISO date or git relative date." },
1402
+ author: { type: "string", description: "Substring match on author name or email." }
1403
+ },
1404
+ required: ["repo"]
1405
+ }
1406
+ },
1407
+ {
1408
+ name: "repo-blame",
1409
+ description: "git blame for a line range in a file. Returns `sha author iso-date line` rows so it's easy to spot when a specific line was introduced. Provide `path` and a `startLine`/`endLine` window (max 500 lines per call).",
1410
+ inputSchema: {
1411
+ type: "object",
1412
+ properties: {
1413
+ repo: { type: "string", description: "Repo full_name or short name." },
1414
+ path: { type: "string", description: "File path inside the repo." },
1415
+ startLine: { type: "number", description: "1-based start line (default 1)." },
1416
+ endLine: { type: "number", description: "1-based end line (default startLine + 200, capped at startLine + 500)." },
1417
+ ref: { type: "string", description: "Branch / tag / SHA to blame against (default HEAD)." }
1418
+ },
1419
+ required: ["repo", "path"]
1420
+ }
1421
+ }
1422
+ ];
1423
+ var REPO_TOOL_NAMES = new Set(REPO_TOOLS.map((t) => t.name));
1424
+ var REPO_TOOL_MODULE_MAP = {
1425
+ "repo-list": "repositories",
1426
+ "repo-tree": "repositories",
1427
+ "repo-read": "repositories",
1428
+ "repo-search": "repositories",
1429
+ "repo-find-symbol": "repositories",
1430
+ "repo-log": "repositories",
1431
+ "repo-blame": "repositories"
1432
+ };
1433
+ var READ_INLINE_LIMIT = 1024 * 1024;
1434
+ var SEARCH_DEFAULT_MAX = 200;
1435
+ var SEARCH_HARD_CAP = 2e3;
1436
+ var TREE_DEFAULT_MAX = 2e3;
1437
+ var TREE_HARD_CAP = 2e4;
1438
+ var LOG_DEFAULT_MAX = 25;
1439
+ var LOG_HARD_CAP = 500;
1440
+ var BLAME_WINDOW_DEFAULT = 200;
1441
+ var BLAME_WINDOW_MAX = 500;
1442
+ var FAN_OUT_HARD_CAP = 20;
1443
+ var hostCache = null;
1444
+ function bashQuote(s) {
1445
+ return `'${s.replace(/'/g, "'\\''")}'`;
1446
+ }
1447
+ async function resolveRepoHost(deps) {
1448
+ if (hostCache && hostCache.expiresAt > Date.now()) return hostCache;
1449
+ const rows = await deps.db.execute(sql`
1450
+ SELECT id FROM ssh_server
1451
+ WHERE tags @> ARRAY['mcp-repo-host']::text[]
1452
+ LIMIT 2
1453
+ `);
1454
+ if (rows.length === 0) {
1455
+ throw new Error(
1456
+ 'No SSH server has the "mcp-repo-host" tag. Add the tag to exactly one server to enable repo-* tools.'
1457
+ );
1458
+ }
1459
+ const serverId = rows[0].id;
1460
+ const { conn, proxy } = await deps.getServerConnection(serverId);
1461
+ hostCache = {
1462
+ expiresAt: Date.now() + 6e4,
1463
+ serverId,
1464
+ conn,
1465
+ proxy
1466
+ };
1467
+ return hostCache;
1468
+ }
1469
+ async function resolveRepo(deps, repoInput) {
1470
+ const trimmed = repoInput.trim();
1471
+ if (!trimmed) throw new Error("repo: empty input");
1472
+ const isFullName = trimmed.includes("/");
1473
+ const rows = isFullName ? await deps.db.execute(sql`
1474
+ SELECT id, org, name, full_name, default_branch, visibility, description,
1475
+ topics, enabled, clone_path, last_pulled_at, last_sync_error
1476
+ FROM mcp_repositories
1477
+ WHERE enabled = true AND full_name = ${trimmed}
1478
+ LIMIT 5
1479
+ `) : await deps.db.execute(sql`
1480
+ SELECT id, org, name, full_name, default_branch, visibility, description,
1481
+ topics, enabled, clone_path, last_pulled_at, last_sync_error
1482
+ FROM mcp_repositories
1483
+ WHERE enabled = true AND name = ${trimmed}
1484
+ LIMIT 5
1485
+ `);
1486
+ if (rows.length === 0) {
1487
+ throw new Error(
1488
+ `Repo "${trimmed}" is not enabled (or not registered). Enable it in the Repositories module first; the sync job will then clone it on the mcp-repo-host.`
1489
+ );
1490
+ }
1491
+ if (rows.length > 1) {
1492
+ const options = rows.map((r) => r.full_name).join(", ");
1493
+ throw new Error(`Ambiguous repo "${trimmed}". Pass the full_name. Candidates: ${options}`);
1494
+ }
1495
+ const row = rows[0];
1496
+ if (!row.clone_path) {
1497
+ throw new Error(
1498
+ `Repo "${row.full_name}" is enabled but has not been cloned yet. Trigger sync-repository-clones or wait for the next scheduled run.`
1499
+ );
1500
+ }
1501
+ return row;
1502
+ }
1503
+ async function resolveRepos(deps, args2) {
1504
+ if (args2.repo) {
1505
+ return [await resolveRepo(deps, args2.repo)];
1506
+ }
1507
+ if (args2.repos && args2.repos.length > 0) {
1508
+ const seen = /* @__PURE__ */ new Set();
1509
+ const out = [];
1510
+ for (const r of args2.repos) {
1511
+ const row = await resolveRepo(deps, r);
1512
+ if (!seen.has(row.id)) {
1513
+ seen.add(row.id);
1514
+ out.push(row);
1515
+ }
1516
+ }
1517
+ return out;
1518
+ }
1519
+ const rows = await deps.db.execute(sql`
1520
+ SELECT id, org, name, full_name, default_branch, visibility, description,
1521
+ topics, enabled, clone_path, last_pulled_at, last_sync_error
1522
+ FROM mcp_repositories
1523
+ WHERE enabled = true AND clone_path IS NOT NULL
1524
+ ORDER BY full_name
1525
+ LIMIT ${FAN_OUT_HARD_CAP}
1526
+ `);
1527
+ return rows;
1528
+ }
1529
+ async function runRemote(deps, host, script, timeoutMs = 6e4) {
1530
+ return deps.sshExec(
1531
+ { ...host.conn, timeout: timeoutMs },
1532
+ `bash -c ${bashQuote(script)}`,
1533
+ host.proxy
1534
+ );
1535
+ }
1536
+ function clampInt(value, def, min, max) {
1537
+ const n = Number(value);
1538
+ if (!Number.isFinite(n)) return def;
1539
+ return Math.min(max, Math.max(min, Math.trunc(n)));
1540
+ }
1541
+ function basenameMatchesGlob(name, pattern) {
1542
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1543
+ const re = new RegExp(`^${escaped}$`, "i");
1544
+ return re.test(name);
1545
+ }
1546
+ async function handleRepoList(deps, args2) {
1547
+ const enabledOnly = args2.enabledOnly !== false;
1548
+ const limit = clampInt(args2.limit, 200, 1, 1e3);
1549
+ const search = typeof args2.search === "string" ? args2.search.trim().toLowerCase() : "";
1550
+ const orgFilter = typeof args2.org === "string" ? args2.org.trim() : "";
1551
+ const topicFilter = typeof args2.topic === "string" ? args2.topic.trim() : "";
1552
+ const conds = [sql`TRUE`];
1553
+ if (enabledOnly) conds.push(sql`enabled = true`);
1554
+ if (orgFilter) conds.push(sql`org = ${orgFilter}`);
1555
+ if (topicFilter) conds.push(sql`topics @> ARRAY[${topicFilter}]::text[]`);
1556
+ const where = sql.join(conds, sql` AND `);
1557
+ const rowsRaw = await deps.db.execute(sql`
1558
+ SELECT id, org, name, full_name, default_branch, visibility, description,
1559
+ topics, enabled, clone_path, last_pulled_at, last_sync_error
1560
+ FROM mcp_repositories
1561
+ WHERE ${where}
1562
+ ORDER BY full_name
1563
+ LIMIT ${limit}
1564
+ `);
1565
+ let rows = [...rowsRaw];
1566
+ if (search) {
1567
+ rows = rows.filter((r) => {
1568
+ const a = r.full_name.toLowerCase();
1569
+ const b = (r.description || "").toLowerCase();
1570
+ return a.includes(search) || b.includes(search);
1571
+ });
1572
+ }
1573
+ if (rows.length === 0) {
1574
+ const hint = enabledOnly ? "No enabled repositories match. Pass enabledOnly: false to also see disabled rows, or run discover-repositories first." : "No repositories registered. Run the discover-repositories task to populate this table.";
1575
+ return { content: [{ type: "text", text: hint }] };
1576
+ }
1577
+ const header = `${"FULL_NAME".padEnd(45)} ${"BRANCH".padEnd(15)} ${"VIS".padEnd(8)} ${"ENABLED".padEnd(8)} ${"LAST_PULLED".padEnd(20)} CLONE_PATH`;
1578
+ const lines = rows.map((r) => {
1579
+ const pulled = r.last_pulled_at ? new Date(r.last_pulled_at).toISOString().slice(0, 19).replace("T", " ") : "never";
1580
+ const err = r.last_sync_error ? ` ERR:${r.last_sync_error.slice(0, 40)}` : "";
1581
+ return `${r.full_name.padEnd(45)} ${(r.default_branch || "").padEnd(15)} ${(r.visibility || "").padEnd(8)} ${(r.enabled ? "yes" : "no").padEnd(8)} ${pulled.padEnd(20)} ${r.clone_path || "-"}${err}`;
1582
+ });
1583
+ return {
1584
+ content: [
1585
+ { type: "text", text: `${header}
1586
+ ${"-".repeat(header.length)}
1587
+ ${lines.join("\n")}` }
1588
+ ]
1589
+ };
1590
+ }
1591
+ async function handleRepoTree(deps, args2) {
1592
+ const row = await resolveRepo(deps, String(args2.repo || ""));
1593
+ const host = await resolveRepoHost(deps);
1594
+ const ref = typeof args2.ref === "string" && args2.ref ? args2.ref : row.default_branch;
1595
+ const path = typeof args2.path === "string" ? args2.path.replace(/^\/+|\/+$/g, "") : "";
1596
+ const recursive = args2.recursive === true;
1597
+ const maxResults = clampInt(args2.maxResults, TREE_DEFAULT_MAX, 1, TREE_HARD_CAP);
1598
+ const pattern = typeof args2.pattern === "string" ? args2.pattern : "";
1599
+ const lsArgs = recursive ? "-r" : "";
1600
+ const refSpec = path ? `${ref}:${bashQuote(path)}` : bashQuote(ref);
1601
+ const cmd = `git -C ${bashQuote(row.clone_path)} ls-tree ${lsArgs} --long ${refSpec} 2>&1 | head -n ${maxResults + 1}`;
1602
+ const res = await runRemote(deps, host, cmd, 3e4);
1603
+ if (res.exitCode !== 0) {
1604
+ return { content: [{ type: "text", text: `Error: ${(res.stderr || res.stdout || "git ls-tree failed").trim().slice(0, 500)}` }] };
1605
+ }
1606
+ const lines = [];
1607
+ for (const raw of res.stdout.split("\n")) {
1608
+ const line = raw.trimEnd();
1609
+ if (!line) continue;
1610
+ const tabIdx = line.indexOf(" ");
1611
+ if (tabIdx === -1) continue;
1612
+ const left = line.slice(0, tabIdx).trim().split(/\s+/);
1613
+ const entryPath = line.slice(tabIdx + 1);
1614
+ if (left.length < 4) continue;
1615
+ const [mode, type, object, size] = [left[0], left[1], left[2], left[3]];
1616
+ if (pattern) {
1617
+ const base = entryPath.split("/").pop() || entryPath;
1618
+ if (!basenameMatchesGlob(base, pattern)) continue;
1619
+ }
1620
+ lines.push({ mode, type, object, size, path: entryPath });
1621
+ if (lines.length >= maxResults) break;
1622
+ }
1623
+ if (lines.length === 0) {
1624
+ return { content: [{ type: "text", text: `No entries at ${row.full_name}@${ref}${path ? `:${path}` : ""}` }] };
1625
+ }
1626
+ const header = `${"TYPE".padEnd(6)} ${"SIZE".padEnd(10)} ${"OBJECT".padEnd(12)} PATH`;
1627
+ const rendered = lines.map((e) => {
1628
+ const size = e.type === "blob" ? e.size : "-";
1629
+ return `${e.type.padEnd(6)} ${size.padEnd(10)} ${e.object.slice(0, 12).padEnd(12)} ${e.path}`;
1630
+ });
1631
+ const footer = lines.length >= maxResults ? `
1632
+
1633
+ [truncated at ${maxResults} entries \u2014 pass maxResults to widen]` : "";
1634
+ return {
1635
+ content: [
1636
+ { type: "text", text: `repo: ${row.full_name} ref: ${ref}${path ? ` path: ${path}` : ""}
1637
+ ${header}
1638
+ ${"-".repeat(header.length)}
1639
+ ${rendered.join("\n")}${footer}` }
1640
+ ]
1641
+ };
1642
+ }
1643
+ async function handleRepoRead(deps, args2) {
1644
+ const row = await resolveRepo(deps, String(args2.repo || ""));
1645
+ const host = await resolveRepoHost(deps);
1646
+ const filePath = String(args2.path || "").replace(/^\/+/, "");
1647
+ if (!filePath) return { content: [{ type: "text", text: "Error: path is required" }] };
1648
+ const ref = typeof args2.ref === "string" && args2.ref ? args2.ref : row.default_branch;
1649
+ const ranged = args2.offset !== void 0 || args2.length !== void 0;
1650
+ const offset = clampInt(args2.offset, 0, 0, Number.MAX_SAFE_INTEGER);
1651
+ const length = clampInt(
1652
+ args2.length,
1653
+ READ_INLINE_LIMIT,
1654
+ 1,
1655
+ READ_INLINE_LIMIT
1656
+ );
1657
+ const SENTINEL = "__MCP_REPO_READ_BODY__";
1658
+ const refSpec = `${ref}:${filePath}`;
1659
+ let body;
1660
+ if (ranged) {
1661
+ body = `git -C ${bashQuote(row.clone_path)} show ${bashQuote(refSpec)} | tail -c +${offset + 1} | head -c ${length}`;
1662
+ } else {
1663
+ body = `git -C ${bashQuote(row.clone_path)} show ${bashQuote(refSpec)} | head -c ${READ_INLINE_LIMIT}`;
1664
+ }
1665
+ const cmd = `set -o pipefail; SZ=$(git -C ${bashQuote(row.clone_path)} cat-file -s ${bashQuote(refSpec)}) && echo "$SZ" && echo "${SENTINEL}" && ${body}`;
1666
+ const res = await runRemote(deps, host, cmd, 6e4);
1667
+ if (res.exitCode !== 0) {
1668
+ return {
1669
+ content: [
1670
+ { type: "text", text: `Error: ${(res.stderr || res.stdout || "git show failed").trim().slice(0, 500)}` }
1671
+ ]
1672
+ };
1673
+ }
1674
+ const idx = res.stdout.indexOf(`${SENTINEL}
1675
+ `);
1676
+ if (idx === -1) {
1677
+ return { content: [{ type: "text", text: `Error: malformed output reading ${filePath}` }] };
1678
+ }
1679
+ const totalSize = Number(res.stdout.slice(0, idx).trim());
1680
+ const content = res.stdout.slice(idx + SENTINEL.length + 1);
1681
+ if (ranged) {
1682
+ const end = Math.min(totalSize, offset + length) - 1;
1683
+ const header = `# range: bytes ${offset}-${end} of ${totalSize}
1684
+ `;
1685
+ return { content: [{ type: "text", text: header + content }] };
1686
+ }
1687
+ if (totalSize > READ_INLINE_LIMIT) {
1688
+ const header = `# truncated: showing first ${READ_INLINE_LIMIT} bytes of ${totalSize} \u2014 use offset/length for ranged reads
1689
+ `;
1690
+ return { content: [{ type: "text", text: header + content }] };
1691
+ }
1692
+ return { content: [{ type: "text", text: content }] };
1693
+ }
1694
+ async function handleRepoSearch(deps, args2) {
1695
+ const pattern = String(args2.pattern || "");
1696
+ if (!pattern) return { content: [{ type: "text", text: "Error: pattern is required" }] };
1697
+ const rows = await resolveRepos(deps, {
1698
+ repo: typeof args2.repo === "string" ? args2.repo : void 0,
1699
+ repos: Array.isArray(args2.repos) ? args2.repos.map(String) : void 0
1700
+ });
1701
+ if (rows.length === 0) {
1702
+ return { content: [{ type: "text", text: "No enabled, cloned repositories available to search." }] };
1703
+ }
1704
+ const host = await resolveRepoHost(deps);
1705
+ const path = typeof args2.path === "string" ? args2.path.replace(/^\/+|\/+$/g, "") : "";
1706
+ const glob = typeof args2.glob === "string" ? args2.glob : "";
1707
+ const caseSensitive = args2.caseSensitive === true;
1708
+ const context = clampInt(args2.context, 0, 0, 5);
1709
+ const cap = clampInt(args2.maxResults, SEARCH_DEFAULT_MAX, 1, SEARCH_HARD_CAP);
1710
+ const rgFlags = [
1711
+ "--with-filename",
1712
+ "--line-number",
1713
+ "--color=never",
1714
+ caseSensitive ? "" : "--smart-case",
1715
+ context > 0 ? `-C ${context}` : "",
1716
+ glob ? `--glob ${bashQuote(glob)}` : "",
1717
+ `--max-count ${Math.min(cap, 200)}`
1718
+ ].filter(Boolean).join(" ");
1719
+ const sections = [];
1720
+ let remaining = cap;
1721
+ for (const row of rows) {
1722
+ if (remaining <= 0) break;
1723
+ const target = path ? `${row.clone_path}/${path}` : row.clone_path;
1724
+ const cmd = `rg ${rgFlags} -e ${bashQuote(pattern)} ${bashQuote(target)} 2>&1 | head -n ${remaining * (context * 2 + 1) + 200}`;
1725
+ const res = await runRemote(deps, host, cmd, 6e4);
1726
+ if (res.exitCode !== 0 && res.exitCode !== 1) {
1727
+ sections.push(`=== ${row.full_name} ===
1728
+ Error: ${(res.stderr || res.stdout || "rg failed").trim().slice(0, 300)}`);
1729
+ continue;
1730
+ }
1731
+ const body = res.stdout.trim();
1732
+ if (!body) {
1733
+ sections.push(`=== ${row.full_name} ===
1734
+ (no matches)`);
1735
+ continue;
1736
+ }
1737
+ const prefix = `${row.clone_path}/`;
1738
+ const cleaned = body.split("\n").map((l) => l.startsWith(prefix) ? l.slice(prefix.length) : l).join("\n");
1739
+ const matchLines = cleaned.split("\n").filter((l) => /:\d+:/.test(l)).length;
1740
+ remaining -= matchLines;
1741
+ sections.push(`=== ${row.full_name} ===
1742
+ ${cleaned}`);
1743
+ }
1744
+ const footer = remaining <= 0 ? `
1745
+
1746
+ [hit cap of ${cap} matches across repos \u2014 narrow the pattern or pass maxResults]` : "";
1747
+ return { content: [{ type: "text", text: sections.join("\n\n") + footer }] };
1748
+ }
1749
+ var SYMBOL_PRESETS = {
1750
+ ts: {
1751
+ exts: ["ts", "tsx"],
1752
+ pattern: (s) => `(export\\s+(default\\s+)?)?(async\\s+)?(class|interface|type|enum|function|const|let|var)\\s+${s}\\b|\\b${s}\\s*[:=]\\s*(async\\s*)?\\(|^\\s*${s}\\s*\\(`
1753
+ },
1754
+ js: {
1755
+ exts: ["js", "jsx", "mjs", "cjs"],
1756
+ pattern: (s) => `(export\\s+(default\\s+)?)?(async\\s+)?(class|function|const|let|var)\\s+${s}\\b|\\b${s}\\s*[:=]\\s*(async\\s*)?\\(`
1757
+ },
1758
+ go: {
1759
+ exts: ["go"],
1760
+ pattern: (s) => `^func\\s+(\\([^)]*\\)\\s+)?${s}\\b|^type\\s+${s}\\b|^var\\s+${s}\\b|^const\\s+${s}\\b`
1761
+ },
1762
+ py: {
1763
+ exts: ["py"],
1764
+ pattern: (s) => `^\\s*(async\\s+)?def\\s+${s}\\b|^\\s*class\\s+${s}\\b`
1765
+ },
1766
+ rs: {
1767
+ exts: ["rs"],
1768
+ pattern: (s) => `\\b(fn|struct|enum|trait|type|const|static)\\s+${s}\\b`
1769
+ },
1770
+ java: {
1771
+ exts: ["java", "kt"],
1772
+ pattern: (s) => `(public|private|protected)?\\s*(static\\s+)?(class|interface|enum|record)\\s+${s}\\b|\\b\\w+\\s+${s}\\s*\\(`
1773
+ },
1774
+ cs: {
1775
+ exts: ["cs"],
1776
+ pattern: (s) => `(public|private|protected|internal)\\s+(static\\s+)?(class|interface|struct|enum|record)\\s+${s}\\b|\\b${s}\\s*\\(`
1777
+ },
1778
+ php: {
1779
+ exts: ["php"],
1780
+ pattern: (s) => `\\b(function|class|interface|trait)\\s+${s}\\b|\\bconst\\s+${s}\\b`
1781
+ }
1782
+ };
1783
+ async function handleRepoFindSymbol(deps, args2) {
1784
+ const symbol = String(args2.symbol || "").trim();
1785
+ if (!symbol) return { content: [{ type: "text", text: "Error: symbol is required" }] };
1786
+ if (!/^[A-Za-z_$][\w$]*$/.test(symbol)) {
1787
+ return {
1788
+ content: [
1789
+ {
1790
+ type: "text",
1791
+ text: `Error: symbol "${symbol}" must be a plain identifier (letters, digits, underscore, $). Use repo-search for regex matches.`
1792
+ }
1793
+ ]
1794
+ };
1795
+ }
1796
+ const rows = await resolveRepos(deps, {
1797
+ repo: typeof args2.repo === "string" ? args2.repo : void 0,
1798
+ repos: Array.isArray(args2.repos) ? args2.repos.map(String) : void 0
1799
+ });
1800
+ if (rows.length === 0) {
1801
+ return { content: [{ type: "text", text: "No enabled, cloned repositories available to search." }] };
1802
+ }
1803
+ const host = await resolveRepoHost(deps);
1804
+ const langs = Array.isArray(args2.languages) && args2.languages.length > 0 ? args2.languages.map(String).filter((l) => l in SYMBOL_PRESETS) : Object.keys(SYMBOL_PRESETS);
1805
+ const cap = clampInt(args2.maxResults, 50, 1, 500);
1806
+ const sections = [];
1807
+ let remaining = cap;
1808
+ for (const row of rows) {
1809
+ if (remaining <= 0) break;
1810
+ const perLang = [];
1811
+ const extGlobs = [];
1812
+ for (const lang of langs) {
1813
+ const preset = SYMBOL_PRESETS[lang];
1814
+ perLang.push(`(?:${preset.pattern(symbol)})`);
1815
+ for (const ext of preset.exts) extGlobs.push(`*.${ext}`);
1816
+ }
1817
+ const combined = perLang.join("|");
1818
+ const globArgs = extGlobs.map((g) => `--glob ${bashQuote(g)}`).join(" ");
1819
+ const cmd = `rg --with-filename --line-number --color=never --smart-case ${globArgs} -e ${bashQuote(combined)} ${bashQuote(row.clone_path)} 2>&1 | head -n ${remaining * 2}`;
1820
+ const res = await runRemote(deps, host, cmd, 45e3);
1821
+ if (res.exitCode !== 0 && res.exitCode !== 1) {
1822
+ sections.push(`=== ${row.full_name} ===
1823
+ Error: ${(res.stderr || res.stdout || "rg failed").trim().slice(0, 300)}`);
1824
+ continue;
1825
+ }
1826
+ const body = res.stdout.trim();
1827
+ if (!body) {
1828
+ sections.push(`=== ${row.full_name} ===
1829
+ (no match for "${symbol}")`);
1830
+ continue;
1831
+ }
1832
+ const prefix = `${row.clone_path}/`;
1833
+ const cleaned = body.split("\n").map((l) => l.startsWith(prefix) ? l.slice(prefix.length) : l).join("\n");
1834
+ const matchCount = cleaned.split("\n").length;
1835
+ remaining -= matchCount;
1836
+ sections.push(`=== ${row.full_name} ===
1837
+ ${cleaned}`);
1838
+ }
1839
+ const footer = remaining <= 0 ? `
1840
+
1841
+ [hit cap of ${cap} matches \u2014 pass maxResults to widen]` : "";
1842
+ return { content: [{ type: "text", text: sections.join("\n\n") + footer }] };
1843
+ }
1844
+ async function handleRepoLog(deps, args2) {
1845
+ const row = await resolveRepo(deps, String(args2.repo || ""));
1846
+ const host = await resolveRepoHost(deps);
1847
+ const limit = clampInt(args2.limit, LOG_DEFAULT_MAX, 1, LOG_HARD_CAP);
1848
+ const ref = typeof args2.ref === "string" && args2.ref ? args2.ref : row.default_branch;
1849
+ const path = typeof args2.path === "string" ? args2.path.replace(/^\/+/, "") : "";
1850
+ const since = typeof args2.since === "string" ? args2.since : "";
1851
+ const until = typeof args2.until === "string" ? args2.until : "";
1852
+ const author = typeof args2.author === "string" ? args2.author : "";
1853
+ const fmt = "%H%x09%an%x09%aI%x09%s";
1854
+ const optParts = [
1855
+ `-n ${limit}`,
1856
+ `--pretty=format:${fmt}`,
1857
+ since ? `--since=${bashQuote(since)}` : "",
1858
+ until ? `--until=${bashQuote(until)}` : "",
1859
+ author ? `--author=${bashQuote(author)}` : ""
1860
+ ].filter(Boolean).join(" ");
1861
+ const refPart = bashQuote(ref);
1862
+ const pathPart = path ? ` -- ${bashQuote(path)}` : "";
1863
+ const cmd = `git -C ${bashQuote(row.clone_path)} log ${optParts} ${refPart}${pathPart} 2>&1`;
1864
+ const res = await runRemote(deps, host, cmd, 45e3);
1865
+ if (res.exitCode !== 0) {
1866
+ return { content: [{ type: "text", text: `Error: ${(res.stderr || res.stdout || "git log failed").trim().slice(0, 500)}` }] };
1867
+ }
1868
+ const lines = res.stdout.trim().split("\n").filter(Boolean);
1869
+ if (lines.length === 0) {
1870
+ return { content: [{ type: "text", text: `No commits found for ${row.full_name}@${ref}${path ? `:${path}` : ""}` }] };
1871
+ }
1872
+ const rendered = lines.map((l) => {
1873
+ const parts = l.split(" ");
1874
+ const sha = (parts[0] || "").slice(0, 12);
1875
+ const author2 = parts[1] || "";
1876
+ const date = parts[2] || "";
1877
+ const subject = parts[3] || "";
1878
+ return `${sha.padEnd(12)} ${date.padEnd(25)} ${author2.padEnd(25)} ${subject}`;
1879
+ });
1880
+ const header = `${"SHA".padEnd(12)} ${"DATE".padEnd(25)} ${"AUTHOR".padEnd(25)} SUBJECT`;
1881
+ return { content: [{ type: "text", text: `${header}
1882
+ ${"-".repeat(header.length)}
1883
+ ${rendered.join("\n")}` }] };
1884
+ }
1885
+ async function handleRepoBlame(deps, args2) {
1886
+ const row = await resolveRepo(deps, String(args2.repo || ""));
1887
+ const host = await resolveRepoHost(deps);
1888
+ const filePath = String(args2.path || "").replace(/^\/+/, "");
1889
+ if (!filePath) return { content: [{ type: "text", text: "Error: path is required" }] };
1890
+ const ref = typeof args2.ref === "string" && args2.ref ? args2.ref : row.default_branch;
1891
+ const startLine = clampInt(args2.startLine, 1, 1, Number.MAX_SAFE_INTEGER);
1892
+ const endLine = clampInt(
1893
+ args2.endLine,
1894
+ startLine + BLAME_WINDOW_DEFAULT - 1,
1895
+ startLine,
1896
+ startLine + BLAME_WINDOW_MAX - 1
1897
+ );
1898
+ const cmd = `git -C ${bashQuote(row.clone_path)} blame --line-porcelain -L ${startLine},${endLine} ${bashQuote(ref)} -- ${bashQuote(filePath)} 2>&1`;
1899
+ const res = await runRemote(deps, host, cmd, 45e3);
1900
+ if (res.exitCode !== 0) {
1901
+ return { content: [{ type: "text", text: `Error: ${(res.stderr || res.stdout || "git blame failed").trim().slice(0, 500)}` }] };
1902
+ }
1903
+ const blocks = res.stdout.split("\n ");
1904
+ const out = [];
1905
+ for (const block of blocks) {
1906
+ const lines = block.split("\n");
1907
+ let sha = "";
1908
+ let author = "";
1909
+ let authorTime = "";
1910
+ let authorTz = "";
1911
+ let sourceLine = "";
1912
+ let inHeader = true;
1913
+ for (const raw of lines) {
1914
+ if (inHeader) {
1915
+ if (!sha) {
1916
+ const m = raw.match(/^([0-9a-f]{40})\s/);
1917
+ if (m) {
1918
+ sha = m[1];
1919
+ continue;
1920
+ }
1921
+ }
1922
+ if (raw.startsWith("author ")) author = raw.slice(7);
1923
+ else if (raw.startsWith("author-time ")) authorTime = raw.slice(12);
1924
+ else if (raw.startsWith("author-tz ")) authorTz = raw.slice(10);
1925
+ else if (raw.startsWith(" ")) {
1926
+ sourceLine = raw.slice(1);
1927
+ inHeader = false;
1928
+ }
1929
+ } else {
1930
+ sourceLine += "\n" + raw;
1931
+ }
1932
+ }
1933
+ if (!sha) continue;
1934
+ const date = authorTime ? new Date(Number(authorTime) * 1e3).toISOString().slice(0, 19) : "";
1935
+ out.push(`${sha.slice(0, 12)} ${date}${authorTz ? " " + authorTz : ""} ${author.padEnd(25)} ${sourceLine}`);
1936
+ }
1937
+ if (out.length === 0) {
1938
+ return { content: [{ type: "text", text: `No blame data for ${row.full_name}:${filePath} L${startLine}-${endLine}` }] };
1939
+ }
1940
+ return {
1941
+ content: [
1942
+ { type: "text", text: `repo: ${row.full_name} file: ${filePath} ref: ${ref} lines: ${startLine}-${endLine}
1943
+ ${out.join("\n")}` }
1944
+ ]
1945
+ };
1946
+ }
1947
+ async function handleRepoTool(name, args2, deps) {
1948
+ switch (name) {
1949
+ case "repo-list":
1950
+ return handleRepoList(deps, args2);
1951
+ case "repo-tree":
1952
+ return handleRepoTree(deps, args2);
1953
+ case "repo-read":
1954
+ return handleRepoRead(deps, args2);
1955
+ case "repo-search":
1956
+ return handleRepoSearch(deps, args2);
1957
+ case "repo-find-symbol":
1958
+ return handleRepoFindSymbol(deps, args2);
1959
+ case "repo-log":
1960
+ return handleRepoLog(deps, args2);
1961
+ case "repo-blame":
1962
+ return handleRepoBlame(deps, args2);
1963
+ default:
1964
+ return { content: [{ type: "text", text: `Unknown repo tool: ${name}` }] };
1965
+ }
1966
+ }
1251
1967
  var args = process.argv.slice(2);
1252
1968
  function getArg2(name) {
1253
1969
  return args.find((a) => a.startsWith(`--${name}=`))?.split("=").slice(1).join("=");
@@ -1260,8 +1976,7 @@ if (proxyUrl) {
1260
1976
  }
1261
1977
  var apiKey = getArg2("api-key") || process.env.MG_DASHBOARD_API_KEY;
1262
1978
  var sshKeyPath = getArg2("ssh-key") || process.env.MG_DASHBOARD_SSH_KEY;
1263
- var supabaseUrl = getArg2("supabase-url") || process.env.SUPABASE_URL;
1264
- var supabaseKey = getArg2("supabase-key") || process.env.SUPABASE_SERVICE_ROLE_KEY;
1979
+ var databaseUrl = getArg2("database-url") || process.env.DATABASE_PRIMARY_POOLER_URL || process.env.DATABASE_PRIMARY_URL;
1265
1980
  var encryptionKey = getArg2("encryption-key") || process.env.ENCRYPTION_KEY;
1266
1981
  var mijnhostApiKey = getArg2("mijnhost-api-key") || process.env.MIJNHOST_API_KEY;
1267
1982
  var httpMode = args.includes("--http");
@@ -1270,11 +1985,14 @@ if (!apiKey || !sshKeyPath) {
1270
1985
  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.");
1271
1986
  process.exit(1);
1272
1987
  }
1273
- if (!supabaseUrl || !supabaseKey) {
1274
- console.error("Supabase credentials required. Use --supabase-url and --supabase-key or set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY");
1988
+ if (!databaseUrl) {
1989
+ console.error("Database URL required. Use --database-url=postgres://... or set DATABASE_PRIMARY_URL (or DATABASE_PRIMARY_POOLER_URL).");
1275
1990
  process.exit(1);
1276
1991
  }
1277
- var supabase = createClient(supabaseUrl, supabaseKey);
1992
+ if (!process.env.DATABASE_PRIMARY_URL && !process.env.DATABASE_PRIMARY_POOLER_URL) {
1993
+ process.env.DATABASE_PRIMARY_URL = databaseUrl;
1994
+ }
1995
+ var db = getDb();
1278
1996
  var RateLimiter = class {
1279
1997
  buckets = /* @__PURE__ */ new Map();
1280
1998
  maxAttempts;
@@ -1337,17 +2055,23 @@ function redactSensitiveArgs(args2) {
1337
2055
  async function writeAuditLog(entry) {
1338
2056
  if (!authContext) return;
1339
2057
  try {
1340
- await supabase.from("mcp_audit_log").insert({
1341
- api_key_id: authContext.apiKeyId,
1342
- user_id: authContext.userId,
1343
- tool_name: entry.toolName,
1344
- arguments: entry.arguments ? redactSensitiveArgs(entry.arguments) : null,
1345
- ip_address: entry.ipAddress || null,
1346
- server_id: entry.serverId || null,
1347
- result_status: entry.resultStatus,
1348
- error_message: entry.errorMessage?.slice(0, 1e3) || null,
1349
- duration_ms: entry.durationMs || null
1350
- });
2058
+ const argsJson = entry.arguments ? JSON.stringify(redactSensitiveArgs(entry.arguments)) : null;
2059
+ await db.execute(sql`
2060
+ INSERT INTO mcp_audit_log (
2061
+ api_key_id, user_id, tool_name, arguments, ip_address,
2062
+ server_id, result_status, error_message, duration_ms
2063
+ ) VALUES (
2064
+ ${authContext.apiKeyId},
2065
+ ${authContext.userId},
2066
+ ${entry.toolName},
2067
+ ${argsJson}::jsonb,
2068
+ ${entry.ipAddress || null},
2069
+ ${entry.serverId || null},
2070
+ ${entry.resultStatus},
2071
+ ${entry.errorMessage?.slice(0, 1e3) || null},
2072
+ ${entry.durationMs || null}
2073
+ )
2074
+ `);
1351
2075
  } catch (err) {
1352
2076
  console.error("[Audit] Failed to write audit log:", err instanceof Error ? err.message : err);
1353
2077
  }
@@ -1359,7 +2083,8 @@ var MODULE_KEYS = [
1359
2083
  "wiki",
1360
2084
  "ci_cd",
1361
2085
  "domains",
1362
- "settings"
2086
+ "settings",
2087
+ "repositories"
1363
2088
  ];
1364
2089
  var FULL_PERMISSIONS = {
1365
2090
  modules: Object.fromEntries(MODULE_KEYS.map((k) => [k, true])),
@@ -1411,6 +2136,8 @@ var TOOL_MODULE_MAP = {
1411
2136
  "db-discover": "ssh_servers",
1412
2137
  "db-tables": "ssh_servers",
1413
2138
  "db-query": "ssh_servers",
2139
+ "db-apply-migration": "ssh_servers",
2140
+ "db-list-migrations": "ssh_servers",
1414
2141
  "cache-purge": "ssh_servers",
1415
2142
  "env-list": "ci_cd",
1416
2143
  "env-get": "ci_cd",
@@ -1419,7 +2146,8 @@ var TOOL_MODULE_MAP = {
1419
2146
  "dns-list": "domains",
1420
2147
  "dns-record": "domains",
1421
2148
  ...TRIGGER_TOOL_MODULE_MAP,
1422
- ...VERCEL_TOOL_MODULE_MAP
2149
+ ...VERCEL_TOOL_MODULE_MAP,
2150
+ ...REPO_TOOL_MODULE_MAP
1423
2151
  };
1424
2152
  var authContext = null;
1425
2153
  async function validateApiKey(key) {
@@ -1434,8 +2162,14 @@ async function validateApiKey(key) {
1434
2162
  console.error(`Rate limited: too many failed auth attempts. Retry in ${retryMin} minute(s).`);
1435
2163
  return null;
1436
2164
  }
1437
- const { data, error } = await supabase.from("dashboard_mcp_api_key").select("id, name, created_by, allowed_server_ids, is_active, expires_at").eq("api_key_hash", keyHash).eq("is_active", true).single();
1438
- if (error || !data) {
2165
+ const apiKeyRows = await db.execute(sql`
2166
+ SELECT id, name, created_by, allowed_server_ids, is_active, expires_at
2167
+ FROM dashboard_mcp_api_key
2168
+ WHERE api_key_hash = ${keyHash} AND is_active = true
2169
+ LIMIT 1
2170
+ `);
2171
+ const data = apiKeyRows[0];
2172
+ if (!data) {
1439
2173
  console.error(`API key not found or inactive (${rateCheck.remaining} attempts remaining)`);
1440
2174
  return null;
1441
2175
  }
@@ -1443,20 +2177,31 @@ async function validateApiKey(key) {
1443
2177
  console.error(`API key has expired (${rateCheck.remaining} attempts remaining)`);
1444
2178
  return null;
1445
2179
  }
1446
- const { data: userData, error: userError } = await supabase.from("user").select("permissions, role:role!role_id(name, default_permissions)").eq("id", data.created_by).single();
1447
- if (userError || !userData) {
2180
+ const userRows = await db.execute(sql`
2181
+ SELECT u.permissions, r.name AS role_name, r.default_permissions AS role_default_permissions
2182
+ FROM "user" u
2183
+ LEFT JOIN role r ON r.id = u.role_id
2184
+ WHERE u.id = ${data.created_by}
2185
+ LIMIT 1
2186
+ `);
2187
+ const userData = userRows[0];
2188
+ if (!userData) {
1448
2189
  console.error(`User not found for API key creator: ${data.created_by}`);
1449
2190
  return null;
1450
2191
  }
1451
- const roleName = userData.role?.name || "user";
1452
- const roleDefaults = userData.role?.default_permissions ?? {};
2192
+ const roleName = userData.role_name || "user";
2193
+ const roleDefaults = userData.role_default_permissions ?? {};
1453
2194
  const userOverrides = userData.permissions ?? null;
1454
2195
  const permissions = resolvePermissions(roleName, roleDefaults, userOverrides);
1455
2196
  const allowedServerIds = intersectServerAccess(
1456
2197
  data.allowed_server_ids,
1457
2198
  permissions.resources.ssh_servers
1458
2199
  );
1459
- await supabase.from("dashboard_mcp_api_key").update({ last_used_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", data.id);
2200
+ await db.execute(sql`
2201
+ UPDATE dashboard_mcp_api_key
2202
+ SET last_used_at = ${(/* @__PURE__ */ new Date()).toISOString()}
2203
+ WHERE id = ${data.id}
2204
+ `);
1460
2205
  const moduleCount = MODULE_KEYS.filter((k) => permissions.modules[k]).length;
1461
2206
  console.error(`Authenticated as user ${data.created_by} (role: ${roleName}, modules: ${moduleCount}/${MODULE_KEYS.length})`);
1462
2207
  return {
@@ -1596,8 +2341,14 @@ async function validateSshKey(pubkeyPathInput, expectedApiKeyId) {
1596
2341
  return null;
1597
2342
  }
1598
2343
  }
1599
- const { data: keyRow, error: keyErr } = await supabase.from("dashboard_mcp_ssh_key").select("id, name, api_key_id, is_active").eq("fingerprint_sha256", fingerprint).eq("is_active", true).maybeSingle();
1600
- if (keyErr || !keyRow) {
2344
+ const keyRows = await db.execute(sql`
2345
+ SELECT id, name, api_key_id, is_active
2346
+ FROM dashboard_mcp_ssh_key
2347
+ WHERE fingerprint_sha256 = ${fingerprint} AND is_active = true
2348
+ LIMIT 1
2349
+ `);
2350
+ const keyRow = keyRows[0];
2351
+ if (!keyRow) {
1601
2352
  console.error(
1602
2353
  `SSH key not registered (fingerprint ${fingerprint}; ${rateCheck.remaining} attempts remaining). Add it under MCP API Keys \u2192 SSH Keys in the dashboard.`
1603
2354
  );
@@ -1609,8 +2360,14 @@ async function validateSshKey(pubkeyPathInput, expectedApiKeyId) {
1609
2360
  );
1610
2361
  return null;
1611
2362
  }
1612
- const { data: apiRow, error: apiErr } = await supabase.from("dashboard_mcp_api_key").select("id, name, created_by, allowed_server_ids, is_active, expires_at").eq("id", keyRow.api_key_id).eq("is_active", true).maybeSingle();
1613
- if (apiErr || !apiRow) {
2363
+ const apiRows = await db.execute(sql`
2364
+ SELECT id, name, created_by, allowed_server_ids, is_active, expires_at
2365
+ FROM dashboard_mcp_api_key
2366
+ WHERE id = ${keyRow.api_key_id} AND is_active = true
2367
+ LIMIT 1
2368
+ `);
2369
+ const apiRow = apiRows[0];
2370
+ if (!apiRow) {
1614
2371
  console.error("SSH key is linked to an inactive or missing MCP API key entry.");
1615
2372
  return null;
1616
2373
  }
@@ -1618,13 +2375,20 @@ async function validateSshKey(pubkeyPathInput, expectedApiKeyId) {
1618
2375
  console.error("Linked MCP API key entry has expired.");
1619
2376
  return null;
1620
2377
  }
1621
- const { data: userData, error: userError } = await supabase.from("user").select("permissions, role:role!role_id(name, default_permissions)").eq("id", apiRow.created_by).single();
1622
- if (userError || !userData) {
2378
+ const userRows = await db.execute(sql`
2379
+ SELECT u.permissions, r.name AS role_name, r.default_permissions AS role_default_permissions
2380
+ FROM "user" u
2381
+ LEFT JOIN role r ON r.id = u.role_id
2382
+ WHERE u.id = ${apiRow.created_by}
2383
+ LIMIT 1
2384
+ `);
2385
+ const userData = userRows[0];
2386
+ if (!userData) {
1623
2387
  console.error(`User not found for SSH key creator: ${apiRow.created_by}`);
1624
2388
  return null;
1625
2389
  }
1626
- const roleName = userData.role?.name || "user";
1627
- const roleDefaults = userData.role?.default_permissions ?? {};
2390
+ const roleName = userData.role_name || "user";
2391
+ const roleDefaults = userData.role_default_permissions ?? {};
1628
2392
  const userOverrides = userData.permissions ?? null;
1629
2393
  const permissions = resolvePermissions(roleName, roleDefaults, userOverrides);
1630
2394
  const allowedServerIds = intersectServerAccess(
@@ -1633,8 +2397,8 @@ async function validateSshKey(pubkeyPathInput, expectedApiKeyId) {
1633
2397
  );
1634
2398
  const nowIso = (/* @__PURE__ */ new Date()).toISOString();
1635
2399
  await Promise.all([
1636
- supabase.from("dashboard_mcp_ssh_key").update({ last_used_at: nowIso }).eq("id", keyRow.id),
1637
- supabase.from("dashboard_mcp_api_key").update({ last_used_at: nowIso }).eq("id", apiRow.id)
2400
+ db.execute(sql`UPDATE dashboard_mcp_ssh_key SET last_used_at = ${nowIso} WHERE id = ${keyRow.id}`),
2401
+ db.execute(sql`UPDATE dashboard_mcp_api_key SET last_used_at = ${nowIso} WHERE id = ${apiRow.id}`)
1638
2402
  ]);
1639
2403
  const moduleCount = MODULE_KEYS.filter((k) => permissions.modules[k]).length;
1640
2404
  console.error(
@@ -1657,29 +2421,42 @@ function assertServerAccess(serverId) {
1657
2421
  }
1658
2422
  }
1659
2423
  async function resolveReleaseProfileStageIds(profileName) {
1660
- const { data: profile, error } = await supabase.from("release_profile").select("id, name").ilike("name", profileName).maybeSingle();
1661
- if (error) throw new Error(`Failed to look up release profile: ${error.message}`);
2424
+ const profileRows = await db.execute(sql`
2425
+ SELECT id, name FROM release_profile
2426
+ WHERE name ILIKE ${profileName}
2427
+ LIMIT 1
2428
+ `);
2429
+ const profile = profileRows[0];
1662
2430
  if (!profile) {
1663
- const { data: all } = await supabase.from("release_profile").select("name").order("name");
1664
- const names = (all || []).map((p) => p.name).join(", ");
2431
+ const all = await db.execute(
2432
+ sql`SELECT name FROM release_profile ORDER BY name`
2433
+ );
2434
+ const names = all.map((p) => p.name).join(", ");
1665
2435
  throw new Error(
1666
2436
  `Release profile "${profileName}" not found. Available profiles: ${names || "(none)"}`
1667
2437
  );
1668
2438
  }
1669
- const { data: stages, error: stageErr } = await supabase.from("release_profile_stage").select("id").eq("release_profile_id", profile.id);
1670
- if (stageErr) throw new Error(`Failed to look up stages: ${stageErr.message}`);
1671
- if (!stages || stages.length === 0) {
2439
+ const stages = await db.execute(sql`
2440
+ SELECT id FROM release_profile_stage
2441
+ WHERE release_profile_id = ${profile.id}
2442
+ `);
2443
+ if (stages.length === 0) {
1672
2444
  throw new Error(`Release profile "${profile.name}" has no stages configured`);
1673
2445
  }
1674
2446
  return { stageIds: stages.map((s) => s.id), profileId: profile.id };
1675
2447
  }
1676
2448
  async function getProfileNamesForStageIds(stageIds) {
1677
2449
  if (stageIds.length === 0) return {};
1678
- const { data: stages } = await supabase.from("release_profile_stage").select("id, release_profile_id").in("id", stageIds);
1679
- if (!stages || stages.length === 0) return {};
2450
+ const stages = await db.execute(sql`
2451
+ SELECT id, release_profile_id FROM release_profile_stage
2452
+ WHERE id = ANY(${stageIds}::uuid[])
2453
+ `);
2454
+ if (stages.length === 0) return {};
1680
2455
  const profileIds = [...new Set(stages.map((s) => s.release_profile_id))];
1681
- const { data: profiles } = await supabase.from("release_profile").select("id, name").in("id", profileIds);
1682
- if (!profiles) return {};
2456
+ const profiles = await db.execute(sql`
2457
+ SELECT id, name FROM release_profile
2458
+ WHERE id = ANY(${profileIds}::uuid[])
2459
+ `);
1683
2460
  const profileMap = {};
1684
2461
  for (const p of profiles) profileMap[p.id] = p.name;
1685
2462
  const result = {};
@@ -1773,11 +2550,19 @@ async function attemptVercelSync(appName, environment, knownStageId) {
1773
2550
  try {
1774
2551
  let stageId = knownStageId;
1775
2552
  if (!stageId) {
1776
- const { data: direct } = await supabase.from("env_config").select("release_profile_stage_id").eq("app_name", appName).not("release_profile_stage_id", "is", null).limit(1).single();
1777
- stageId = direct?.release_profile_stage_id;
2553
+ const directRows = await db.execute(sql`
2554
+ SELECT release_profile_stage_id
2555
+ FROM env_config
2556
+ WHERE app_name = ${appName} AND release_profile_stage_id IS NOT NULL
2557
+ LIMIT 1
2558
+ `);
2559
+ stageId = directRows[0]?.release_profile_stage_id;
1778
2560
  }
1779
2561
  if (!stageId) return "Vercel sync skipped: no stage link found";
1780
- const { data: settings } = await supabase.from("app_setting").select("vercel_token_encrypted").maybeSingle();
2562
+ const settingsRows = await db.execute(sql`
2563
+ SELECT vercel_token_encrypted FROM app_setting LIMIT 1
2564
+ `);
2565
+ const settings = settingsRows[0];
1781
2566
  if (!settings?.vercel_token_encrypted) return "Vercel sync skipped: no Vercel token configured";
1782
2567
  let token;
1783
2568
  try {
@@ -1785,7 +2570,12 @@ async function attemptVercelSync(appName, environment, knownStageId) {
1785
2570
  } catch {
1786
2571
  return "Vercel sync failed: could not decrypt Vercel token";
1787
2572
  }
1788
- const { data: stage } = await supabase.from("release_profile_stage").select("id, stage, stage_apps").eq("id", stageId).single();
2573
+ const stageRows = await db.execute(sql`
2574
+ SELECT id, stage, stage_apps FROM release_profile_stage
2575
+ WHERE id = ${stageId}
2576
+ LIMIT 1
2577
+ `);
2578
+ const stage = stageRows[0];
1789
2579
  if (!stage) return "Vercel sync skipped: stage not found";
1790
2580
  const stageType = stage.stage;
1791
2581
  const stageApps = stage.stage_apps || [];
@@ -1793,8 +2583,12 @@ async function attemptVercelSync(appName, environment, knownStageId) {
1793
2583
  (a) => a.deployMethod === "vercel" && a.enabled && a.vercelProjectId
1794
2584
  );
1795
2585
  if (vercelApps.length === 0) return "Vercel sync skipped: no Vercel apps in stage";
1796
- const { data: envConfigs } = await supabase.from("env_config").select("*").eq("release_profile_stage_id", stageId);
1797
- if (!envConfigs || envConfigs.length === 0) return "Vercel sync skipped: no env configs for stage";
2586
+ const envConfigs = await db.execute(sql`
2587
+ SELECT id, app_name, environment, variant, env_data_encrypted, release_profile_stage_id
2588
+ FROM env_config
2589
+ WHERE release_profile_stage_id = ${stageId}
2590
+ `);
2591
+ if (envConfigs.length === 0) return "Vercel sync skipped: no env configs for stage";
1798
2592
  const variantMap = {
1799
2593
  dev: "development",
1800
2594
  staging: "staging",
@@ -1877,8 +2671,14 @@ var SSH_PROXY_SERVER_ID = "03659d55-e194-400d-b82a-bf6457371ded";
1877
2671
  var _proxyConnCache = null;
1878
2672
  async function getProxyConnection() {
1879
2673
  if (_proxyConnCache) return _proxyConnCache;
1880
- const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted").eq("id", SSH_PROXY_SERVER_ID).single();
1881
- if (error || !data) throw new Error("SSH Proxy server not found in database");
2674
+ const rows = await db.execute(sql`
2675
+ SELECT hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted
2676
+ FROM ssh_server
2677
+ WHERE id = ${SSH_PROXY_SERVER_ID}
2678
+ LIMIT 1
2679
+ `);
2680
+ const data = rows[0];
2681
+ if (!data) throw new Error("SSH Proxy server not found in database");
1882
2682
  if (!encryptionKey) throw new Error("ENCRYPTION_KEY required to decrypt server credentials");
1883
2683
  _proxyConnCache = {
1884
2684
  hostname: data.hostname,
@@ -1892,8 +2692,15 @@ async function getProxyConnection() {
1892
2692
  }
1893
2693
  async function getServerConnection(serverId) {
1894
2694
  assertServerAccess(serverId);
1895
- const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted, allowed_ssh_ips, os_type").eq("id", serverId).single();
1896
- if (error || !data) {
2695
+ const rows = await db.execute(sql`
2696
+ SELECT hostname, port, username, password_encrypted, ssh_key_encrypted,
2697
+ ssh_key_passphrase_encrypted, allowed_ssh_ips, os_type
2698
+ FROM ssh_server
2699
+ WHERE id = ${serverId}
2700
+ LIMIT 1
2701
+ `);
2702
+ const data = rows[0];
2703
+ if (!data) {
1897
2704
  const candidates = Array.from(KNOWN_SERVER_NAMES.values()).flatMap((s) => [s.name, s.id]);
1898
2705
  const hits = suggestSimilar(serverId, candidates);
1899
2706
  const hint = hits.length ? ` Did you mean: ${hits.join(", ")}?` : "";
@@ -2981,10 +3788,13 @@ async function resolveServerLabel(serverId, serverIds) {
2981
3788
  const cached = KNOWN_SERVER_NAMES.get(serverId);
2982
3789
  if (cached) return cached.name;
2983
3790
  try {
2984
- const { data } = await supabase.from("ssh_server").select("id, name").eq("id", serverId).maybeSingle();
2985
- if (data?.name) {
2986
- KNOWN_SERVER_NAMES.set(serverId, { id: serverId, name: data.name });
2987
- return data.name;
3791
+ const rows = await db.execute(sql`
3792
+ SELECT id, name FROM ssh_server WHERE id = ${serverId} LIMIT 1
3793
+ `);
3794
+ const name = rows[0]?.name;
3795
+ if (name) {
3796
+ KNOWN_SERVER_NAMES.set(serverId, { id: serverId, name });
3797
+ return name;
2988
3798
  }
2989
3799
  } catch {
2990
3800
  }
@@ -3359,6 +4169,72 @@ done
3359
4169
  function escapeMysqlShell(value) {
3360
4170
  return value.replace(/'/g, "'\\''");
3361
4171
  }
4172
+ async function discoverPostgresContainers(conn, proxy) {
4173
+ const script = `
4174
+ docker ps --format '{{.Names}}' 2>/dev/null | while IFS= read -r name; do
4175
+ [ -n "$name" ] || continue
4176
+ envs=$(docker exec "$name" env 2>/dev/null | grep -E '^POSTGRES_(USER|DB|PASSWORD)=' || true)
4177
+ if [ -z "$envs" ]; then continue; fi
4178
+ user=$(printf '%s\\n' "$envs" | grep '^POSTGRES_USER=' | head -n1 | cut -d= -f2-)
4179
+ db=$(printf '%s\\n' "$envs" | grep '^POSTGRES_DB=' | head -n1 | cut -d= -f2-)
4180
+ if printf '%s\\n' "$envs" | grep -q '^POSTGRES_PASSWORD='; then hp=1; else hp=0; fi
4181
+ port=$(docker port "$name" 5432/tcp 2>/dev/null | head -n1 | awk -F: '{print $NF}')
4182
+ echo "PG|$name|\${db:-postgres}|\${user:-postgres}|$hp|\${port:-}"
4183
+ done
4184
+ `.trim();
4185
+ const result = await sshExec(conn, script, proxy);
4186
+ const out = [];
4187
+ for (const line of result.stdout.split("\n")) {
4188
+ if (!line.startsWith("PG|")) continue;
4189
+ const parts = line.split("|");
4190
+ if (parts.length < 6) continue;
4191
+ const [, container, db2, user, hasPass, hostPort] = parts;
4192
+ if (!container) continue;
4193
+ out.push({
4194
+ container,
4195
+ db: db2 || "postgres",
4196
+ user: user || "postgres",
4197
+ hostPort: hostPort || null,
4198
+ hasPassword: hasPass === "1"
4199
+ });
4200
+ }
4201
+ return out;
4202
+ }
4203
+ async function psqlInContainer(conn, proxy, containerName, dbUser, dbName, scriptSql, flags) {
4204
+ const safeContainer = containerName.replace(/[^a-zA-Z0-9._-]/g, "");
4205
+ const safeUser = dbUser.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
4206
+ const safeDb = dbName.replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
4207
+ const cmd = `docker exec -i -e PGOPTIONS=--client-min-messages=warning ${safeContainer} psql -U ${safeUser} -d ${safeDb} -v ON_ERROR_STOP=1 -P pager=off ${flags}`;
4208
+ const res = await sshExec(conn, cmd, proxy, { stdin: scriptSql });
4209
+ return { stdout: res.stdout || "", stderr: res.stderr || "", exitCode: res.exitCode };
4210
+ }
4211
+ var MIGRATION_LEDGER_DDL = `
4212
+ CREATE TABLE IF NOT EXISTS _mcp_migrations (
4213
+ name TEXT PRIMARY KEY,
4214
+ sha256 TEXT NOT NULL,
4215
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
4216
+ applied_by TEXT
4217
+ );
4218
+ `.trim();
4219
+ function normaliseMigrationSql(sql4) {
4220
+ return sql4.replace(/\r\n/g, "\n").trim() + "\n";
4221
+ }
4222
+ function migrationSha256(sql4) {
4223
+ return createHash("sha256").update(normaliseMigrationSql(sql4), "utf8").digest("hex");
4224
+ }
4225
+ function dollarQuoteTag(value) {
4226
+ let tag = "_mcp";
4227
+ let i = 0;
4228
+ while (value.includes(`$${tag}$`)) {
4229
+ i += 1;
4230
+ tag = `_mcp${i}`;
4231
+ }
4232
+ return tag;
4233
+ }
4234
+ function dollarQuote(value) {
4235
+ const tag = dollarQuoteTag(value);
4236
+ return `$${tag}$${value}$${tag}$`;
4237
+ }
3362
4238
  var BLOCKED_SQL_PATTERNS = [
3363
4239
  /\bDROP\s+DATABASE\b/i,
3364
4240
  /\bDROP\s+TABLE\b/i,
@@ -3763,48 +4639,92 @@ var TOOLS = [
3763
4639
  },
3764
4640
  {
3765
4641
  name: "db-discover",
3766
- description: "Scan /var/www on a server for web applications (WordPress, PrestaShop, Laravel, .env) and list their database credentials. Use this first to find available sites before running other db-* tools.",
4642
+ description: 'Discover databases available on an SSH server. Use this first to find what is reachable before running other db-* tools.\n\nModes (pass via `include` array, default `["www"]`):\n- `"www"` \u2014 scan `/var/www` for WordPress / PrestaShop / Laravel / generic .env apps. Returns sitePath, db name, user, host, port.\n- `"postgres-containers"` \u2014 enumerate running Docker containers that expose `POSTGRES_USER` / `POSTGRES_DB` env vars (vanilla `postgres:*` / `pgvector/*` images). Returns container name, db, user, host port mapping, and whether a password is set. Use the returned (container, db, user) tuple directly with `db-query containerName=\u2026` or `db-apply-migration`.\n\nPass multiple modes to scan in parallel: `{ include: ["www", "postgres-containers"] }`.',
3767
4643
  inputSchema: {
3768
4644
  type: "object",
3769
4645
  properties: {
3770
- serverId: { type: "string", description: "UUID of the SSH server" }
4646
+ serverId: { type: "string", description: "UUID of the SSH server" },
4647
+ include: {
4648
+ type: "array",
4649
+ description: 'Which discovery modes to run. Defaults to `["www"]` for back-compat. Add `"postgres-containers"` to enumerate vanilla Postgres Docker containers with their credentials.',
4650
+ items: { type: "string", enum: ["www", "postgres-containers"] }
4651
+ }
3771
4652
  },
3772
4653
  required: ["serverId"]
3773
4654
  }
3774
4655
  },
3775
4656
  {
3776
4657
  name: "db-tables",
3777
- description: "List all tables in a site database with row counts and sizes. Credentials are auto-discovered from the site config files (wp-config.php, parameters.php, .env).",
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.",
3778
4659
  inputSchema: {
3779
4660
  type: "object",
3780
4661
  properties: {
3781
4662
  serverId: { type: "string", description: "UUID of the SSH server" },
3782
- sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com)" }
4663
+ sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). MySQL autodiscover mode." },
4664
+ 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")' }
3783
4667
  },
3784
- required: ["serverId", "sitePath"]
4668
+ required: ["serverId"]
3785
4669
  }
3786
4670
  },
3787
4671
  {
3788
4672
  name: "db-query",
3789
- description: 'Execute a SQL query against any database. Two 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. Same behaviour as before.\n- Direct via Docker container: pass `containerName` (the container running the DB server). Engine defaults to `postgres`; pass `engine: "mysql"` or `"mssql"` to override. The query is piped via stdin so quotes / `$` / `;` are never re-interpreted.\n\nPostgres example: `{ serverId, containerName: "supabase-db", engine: "postgres", 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, so unbounded scans never blow up the token budget. 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) so you can inspect the query plan without rewriting the query.\n\nSchema introspection: pass `describe: "tablename"` to get columns + indexes (replaces the old db-describe tool, works for mysql, postgres, and mssql). Pass `describe: "*"` to list all tables in the current database. When `describe` is set, `query` is ignored.\n\nDestructive operations (DROP, TRUNCATE, etc.) are blocked.',
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.',
3790
4674
  inputSchema: {
3791
4675
  type: "object",
3792
4676
  properties: {
3793
4677
  serverId: { type: "string", description: "UUID of the SSH server" },
3794
- query: { type: "string", description: "SQL query to execute (ignored when describe is set)" },
4678
+ query: { type: "string", description: "SQL query to execute (ignored when describe is set). Multiple statements separated by `;` are piped together to the client." },
3795
4679
  describe: { type: "string", description: 'Schema introspection shortcut. Pass a table name for columns + indexes, or "*" to list all tables. Works for mysql / postgres / mssql.' },
3796
4680
  sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
3797
- containerName: { type: "string", description: 'Docker container running the DB server (e.g. "supabase-db", "trigger-postgres"). Activates direct-query mode.' },
4681
+ containerName: { type: "string", description: 'Docker container running the DB server (e.g. "refront-postgres-vanilla", "supabase-db"). Activates direct-query mode.' },
3798
4682
  engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
3799
4683
  dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
3800
4684
  dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
3801
4685
  dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." },
3802
4686
  maxRows: { type: "number", description: "Auto-inject LIMIT for unbounded SELECTs. Default 1000, max 10000, set 0 to disable." },
3803
- explain: { type: "boolean", description: "Prepend EXPLAIN (postgres/mysql) or SHOWPLAN (mssql) instead of executing. Useful for slow-query diagnosis." }
4687
+ 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).' }
3804
4689
  },
3805
4690
  required: ["serverId"]
3806
4691
  }
3807
4692
  },
4693
+ {
4694
+ 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" }`.',
4696
+ inputSchema: {
4697
+ type: "object",
4698
+ properties: {
4699
+ serverId: { type: "string", description: "UUID of the SSH server" },
4700
+ 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")' },
4703
+ name: { type: "string", description: "Unique migration name (recommended format: `YYYYMMDDhhmmss_description`)" },
4704
+ sql: { type: "string", description: "Inline SQL string (mutually exclusive with localFile / remoteFile)" },
4705
+ localFile: { type: "string", description: "Path on this machine to a .sql file (read here, streamed via stdin)" },
4706
+ remoteFile: { type: "string", description: "Absolute path of a .sql file already on the server (read with `cat`)" },
4707
+ 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." }
4709
+ },
4710
+ required: ["serverId", "containerName", "name"]
4711
+ }
4712
+ },
4713
+ {
4714
+ 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.',
4716
+ inputSchema: {
4717
+ type: "object",
4718
+ properties: {
4719
+ serverId: { type: "string", description: "UUID of the SSH server" },
4720
+ 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")' },
4723
+ limit: { type: "number", description: "Max number of entries (default 50, max 500)" }
4724
+ },
4725
+ required: ["serverId", "containerName"]
4726
+ }
4727
+ },
3808
4728
  {
3809
4729
  name: "env-list",
3810
4730
  description: "List all stored environment configurations with their release profile names.",
@@ -3900,9 +4820,11 @@ var TOOLS = [
3900
4820
  // ----- Trigger.dev -----
3901
4821
  ...TRIGGER_TOOLS,
3902
4822
  // ----- Vercel -----
3903
- ...VERCEL_TOOLS
4823
+ ...VERCEL_TOOLS,
4824
+ // ----- Repo reference -----
4825
+ ...REPO_TOOLS
3904
4826
  ];
3905
- var MCP_VERSION = "6.0.2";
4827
+ var MCP_VERSION = "6.1.0";
3906
4828
  async function handleListTools() {
3907
4829
  if (!authContext) return { tools: TOOLS };
3908
4830
  const accessible = TOOLS.filter((tool) => {
@@ -3967,14 +4889,18 @@ async function executeToolCall(name, a, _serverId) {
3967
4889
  switch (name) {
3968
4890
  // ----- Servers -----
3969
4891
  case "list-servers": {
3970
- let query = supabase.from("ssh_server").select("id, name, hostname, port, username, tags, hosted_by, os_type, created_at").order("name");
3971
- if (ctx.allowedServerIds !== null) {
3972
- query = query.in("id", ctx.allowedServerIds);
3973
- }
3974
- const { data, error } = await query;
3975
- if (error) throw new Error(error.message);
4892
+ const data = ctx.allowedServerIds !== null ? await db.execute(sql`
4893
+ SELECT id, name, hostname, port, username, tags, hosted_by, os_type, created_at
4894
+ FROM ssh_server
4895
+ WHERE id = ANY(${ctx.allowedServerIds}::uuid[])
4896
+ ORDER BY name
4897
+ `) : await db.execute(sql`
4898
+ SELECT id, name, hostname, port, username, tags, hosted_by, os_type, created_at
4899
+ FROM ssh_server
4900
+ ORDER BY name
4901
+ `);
3976
4902
  const includeStats = a.includeStats === true;
3977
- const servers = data || [];
4903
+ const servers = data;
3978
4904
  rememberServers(servers.map((s) => ({ id: s.id, name: s.name })));
3979
4905
  let statsByServer = /* @__PURE__ */ new Map();
3980
4906
  if (includeStats && servers.length > 0) {
@@ -4102,8 +5028,10 @@ ${contextHints.join("\n")}` : out }] };
4102
5028
  const last = iterations[iterations.length - 1].result;
4103
5029
  let serverName2 = serverId;
4104
5030
  try {
4105
- const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
4106
- if (data?.name) serverName2 = data.name;
5031
+ const nameRows = await db.execute(
5032
+ sql`SELECT name FROM ssh_server WHERE id = ${serverId} LIMIT 1`
5033
+ );
5034
+ if (nameRows[0]?.name) serverName2 = nameRows[0].name;
4107
5035
  } catch {
4108
5036
  }
4109
5037
  return { serverId, serverName: serverName2, os, shell, result: last, iterations };
@@ -4118,8 +5046,10 @@ ${contextHints.join("\n")}` : out }] };
4118
5046
  }
4119
5047
  let serverName = serverId;
4120
5048
  try {
4121
- const { data } = await supabase.from("ssh_server").select("name").eq("id", serverId).maybeSingle();
4122
- if (data?.name) serverName = data.name;
5049
+ const nameRows = await db.execute(
5050
+ sql`SELECT name FROM ssh_server WHERE id = ${serverId} LIMIT 1`
5051
+ );
5052
+ if (nameRows[0]?.name) serverName = nameRows[0].name;
4123
5053
  } catch {
4124
5054
  }
4125
5055
  return { serverId, serverName, os, shell, result, pipelineSegments };
@@ -4851,19 +5781,66 @@ ${trail.join("\n")}` }] };
4851
5781
  // ----- Database -----
4852
5782
  case "db-discover": {
4853
5783
  const { conn, proxy } = await getServerConnection(String(a.serverId));
4854
- const sites = await discoverSiteDatabases(conn, proxy);
4855
- if (!sites.length) {
4856
- return { content: [{ type: "text", text: "No web applications with database configs found in /var/www/" }] };
5784
+ const rawInclude = Array.isArray(a.include) ? a.include.map(String) : ["www"];
5785
+ const include = new Set(rawInclude.filter((m) => m === "www" || m === "postgres-containers"));
5786
+ if (include.size === 0) include.add("www");
5787
+ const sections = [];
5788
+ if (include.has("www")) {
5789
+ const sites = await discoverSiteDatabases(conn, proxy);
5790
+ if (!sites.length) {
5791
+ sections.push("## /var/www\n(none)");
5792
+ } else {
5793
+ const lines = sites.map(
5794
+ (s) => `${s.sitePath} [${s.appType}] db=${s.database} user=${s.user} host=${s.host}:${s.port}`
5795
+ );
5796
+ sections.push(`## /var/www (${sites.length})
5797
+ ${lines.join("\n")}`);
5798
+ }
4857
5799
  }
4858
- const lines = sites.map(
4859
- (s) => `${s.sitePath} [${s.appType}] db=${s.database} user=${s.user} host=${s.host}:${s.port}`
4860
- );
4861
- return { content: [{ type: "text", text: lines.join("\n") }] };
5800
+ if (include.has("postgres-containers")) {
5801
+ const containers = await discoverPostgresContainers(conn, proxy);
5802
+ if (!containers.length) {
5803
+ sections.push("## Postgres containers\n(none \u2014 no running container exposes POSTGRES_USER/POSTGRES_DB)");
5804
+ } else {
5805
+ const lines = containers.map(
5806
+ (c) => `${c.container} db=${c.db} user=${c.user} hostPort=${c.hostPort ?? "(internal-only)"} password=${c.hasPassword ? "set" : "unset"}`
5807
+ );
5808
+ sections.push(`## Postgres containers (${containers.length})
5809
+ ${lines.join("\n")}
5810
+
5811
+ Tip: use these directly with \`db-query containerName=\u2026 dbName=\u2026 dbUser=\u2026\` or \`db-apply-migration\`.`);
5812
+ }
5813
+ }
5814
+ return { content: [{ type: "text", text: sections.join("\n\n") }] };
4862
5815
  }
4863
5816
  case "db-tables": {
4864
5817
  const { conn, proxy } = await getServerConnection(String(a.serverId));
4865
- const sql = "SELECT TABLE_NAME, ENGINE, TABLE_ROWS, ROUND(DATA_LENGTH/1024/1024, 2) AS `Size (MB)`, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME";
4866
- const output = await execSiteMysql(conn, String(a.sitePath), sql, proxy);
5818
+ const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName : "";
5819
+ 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";
5822
+ const sqlText2 = `SELECT n.nspname AS schema,
5823
+ c.relname AS "table",
5824
+ pg_size_pretty(pg_total_relation_size(c.oid)) AS size,
5825
+ c.reltuples::bigint AS estimated_rows
5826
+ FROM pg_class c
5827
+ JOIN pg_namespace n ON n.oid = c.relnamespace
5828
+ WHERE c.relkind = 'r'
5829
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
5830
+ ORDER BY pg_total_relation_size(c.oid) DESC;
5831
+ `;
5832
+ const res = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, sqlText2, "");
5833
+ const output2 = res.stdout.trim() || res.stderr.trim() || "(no output)";
5834
+ if (res.exitCode !== 0 && !res.stdout) {
5835
+ return { content: [{ type: "text", text: `Error (exit ${res.exitCode}): ${output2}` }] };
5836
+ }
5837
+ return { content: [{ type: "text", text: output2 || "No tables found" }] };
5838
+ }
5839
+ if (!a.sitePath) {
5840
+ return { content: [{ type: "text", text: "Error: pass `sitePath` (MySQL autodiscover) or `containerName` (Postgres container)." }] };
5841
+ }
5842
+ const sqlText = "SELECT TABLE_NAME, ENGINE, TABLE_ROWS, ROUND(DATA_LENGTH/1024/1024, 2) AS `Size (MB)`, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME";
5843
+ const output = await execSiteMysql(conn, String(a.sitePath), sqlText, proxy);
4867
5844
  return { content: [{ type: "text", text: output || "No tables found" }] };
4868
5845
  }
4869
5846
  case "db-query": {
@@ -4881,7 +5858,24 @@ ${trail.join("\n")}` }] };
4881
5858
  rawQuery = String(a.query ?? "").trim();
4882
5859
  if (!rawQuery) return { content: [{ type: "text", text: "Error: query (or describe) is required" }] };
4883
5860
  }
4884
- assertSafeSql(rawQuery);
5861
+ const allowDestructivePhrase = "yes-i-understand-this-is-not-logged";
5862
+ const allowDestructive = a.allowDestructive === allowDestructivePhrase;
5863
+ if (!allowDestructive && !describeArg) {
5864
+ try {
5865
+ assertSafeSql(rawQuery);
5866
+ } catch (err) {
5867
+ const msg = err instanceof Error ? err.message : String(err);
5868
+ return {
5869
+ content: [{
5870
+ type: "text",
5871
+ text: `${msg}
5872
+
5873
+ For schema migrations use \`db-apply-migration\` \u2014 it allows DDL and records every change in the \`_mcp_migrations\` ledger for audit and idempotency.
5874
+ For a one-off ad-hoc destructive query, pass \`allowDestructive: "${allowDestructivePhrase}"\`.`
5875
+ }]
5876
+ };
5877
+ }
5878
+ }
4885
5879
  let query = rawQuery.replace(/;\s*$/, "");
4886
5880
  let appliedLimit = false;
4887
5881
  if (explainMode && !describeArg) {
@@ -4900,12 +5894,13 @@ LIMIT ${maxRows + 1}`;
4900
5894
  appliedLimit = true;
4901
5895
  }
4902
5896
  }
5897
+ 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." : "";
4903
5898
  if (!containerName && engine === "mysql") {
4904
5899
  if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
4905
5900
  const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
4906
5901
  const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
4907
5902
  const footer2 = formatDbQueryFooter(output2, appliedLimit, maxRows, explainMode);
4908
- return { content: [{ type: "text", text: (output2 || "Query executed successfully (no output)") + footer2 }] };
5903
+ return { content: [{ type: "text", text: (output2 || "Query executed successfully (no output)") + footer2 + destructiveBanner }] };
4909
5904
  }
4910
5905
  if (!containerName) {
4911
5906
  return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
@@ -4917,8 +5912,8 @@ LIMIT ${maxRows + 1}`;
4917
5912
  let stdinPayload;
4918
5913
  if (engine === "postgres") {
4919
5914
  const user = dbUser || "postgres";
4920
- const db = dbName || "postgres";
4921
- cmd = `docker exec -i ${containerName} psql -U ${user} -d ${db} -P pager=off`;
5915
+ const db2 = dbName || "postgres";
5916
+ cmd = `docker exec -i ${containerName} psql -U ${user} -d ${db2} -P pager=off`;
4922
5917
  stdinPayload = query.endsWith(";") ? query : `${query};`;
4923
5918
  } else if (engine === "mssql") {
4924
5919
  const user = dbUser || "sa";
@@ -4933,10 +5928,10 @@ GO
4933
5928
  `;
4934
5929
  } else {
4935
5930
  const user = dbUser || "root";
4936
- const db = dbName ? ` ${dbName}` : "";
5931
+ const db2 = dbName ? ` ${dbName}` : "";
4937
5932
  const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
4938
5933
  const passArg = dbPass ? `-p${posixQuote(dbPass)} ` : "";
4939
- cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${db}`;
5934
+ cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${db2}`;
4940
5935
  stdinPayload = query.endsWith(";") ? query : `${query};`;
4941
5936
  }
4942
5937
  const result = await sshExec(conn, cmd, proxy, { stdin: stdinPayload });
@@ -4945,38 +5940,233 @@ GO
4945
5940
  return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
4946
5941
  }
4947
5942
  const footer = formatDbQueryFooter(output, appliedLimit, maxRows, explainMode);
4948
- return { content: [{ type: "text", text: output + footer }] };
5943
+ return { content: [{ type: "text", text: output + footer + destructiveBanner }] };
5944
+ }
5945
+ case "db-apply-migration": {
5946
+ const containerName = String(a.containerName || "").replace(/[^a-zA-Z0-9._-]/g, "");
5947
+ 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";
5950
+ const name2 = String(a.name || "").trim();
5951
+ if (!name2) return { content: [{ type: "text", text: 'Error: name is required (e.g. "20260517120000_add_foo")' }] };
5952
+ if (!/^[\w.@:+\-]+$/.test(name2) || name2.length > 200) {
5953
+ return { content: [{ type: "text", text: "Error: name must be \u2264200 chars and contain only [A-Za-z0-9_.@:+-]" }] };
5954
+ }
5955
+ const noTransaction = a.noTransaction === true;
5956
+ const force = a.force === true;
5957
+ const sources = [
5958
+ typeof a.sql === "string" && a.sql ? "sql" : null,
5959
+ typeof a.localFile === "string" && a.localFile ? "localFile" : null,
5960
+ typeof a.remoteFile === "string" && a.remoteFile ? "remoteFile" : null
5961
+ ].filter((v) => v !== null);
5962
+ if (sources.length !== 1) {
5963
+ return { content: [{ type: "text", text: `Error: pass exactly one of \`sql\`, \`localFile\`, or \`remoteFile\` (got ${sources.length || "none"}: ${sources.join(", ") || "\u2014"})` }] };
5964
+ }
5965
+ const { conn, proxy } = await getServerConnection(String(a.serverId));
5966
+ let migrationSql;
5967
+ if (typeof a.sql === "string" && a.sql) {
5968
+ migrationSql = a.sql;
5969
+ } else if (typeof a.localFile === "string" && a.localFile) {
5970
+ const p = a.localFile;
5971
+ if (!isAbsolute(p) && !p.startsWith("./") && !p.startsWith("../")) {
5972
+ return { content: [{ type: "text", text: "Error: localFile must be an absolute path or start with ./ or ../" }] };
5973
+ }
5974
+ try {
5975
+ migrationSql = await readFile(p, "utf8");
5976
+ } catch (err) {
5977
+ return { content: [{ type: "text", text: `Error reading localFile "${p}": ${err instanceof Error ? err.message : String(err)}` }] };
5978
+ }
5979
+ } else {
5980
+ const remotePath = String(a.remoteFile);
5981
+ if (!remotePath.startsWith("/")) {
5982
+ return { content: [{ type: "text", text: "Error: remoteFile must be an absolute path on the SSH server" }] };
5983
+ }
5984
+ const catRes = await sshExec(conn, `cat ${posixQuote(remotePath)}`, proxy);
5985
+ if (catRes.exitCode !== 0) {
5986
+ return { content: [{ type: "text", text: `Error reading remoteFile "${remotePath}" on server: ${catRes.stderr || `exit ${catRes.exitCode}`}` }] };
5987
+ }
5988
+ migrationSql = catRes.stdout;
5989
+ }
5990
+ const normalised = normaliseMigrationSql(migrationSql);
5991
+ if (!normalised.trim()) {
5992
+ return { content: [{ type: "text", text: "Error: resolved SQL is empty" }] };
5993
+ }
5994
+ const sha = migrationSha256(migrationSql);
5995
+ const probeSql = `${MIGRATION_LEDGER_DDL}
5996
+ SELECT sha256 FROM _mcp_migrations WHERE name = ${dollarQuote(name2)};
5997
+ `;
5998
+ const probe = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, probeSql, "-tA");
5999
+ if (probe.exitCode !== 0) {
6000
+ return { content: [{ type: "text", text: `Error initialising ledger on ${containerName} (${dbName}): ${probe.stderr || probe.stdout || `exit ${probe.exitCode}`}` }] };
6001
+ }
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] : "";
6004
+ if (existingSha && existingSha === sha) {
6005
+ return {
6006
+ content: [{
6007
+ type: "text",
6008
+ text: `[noop] Migration "${name2}" already applied on ${containerName}/${dbName} (sha256 matches).
6009
+ Ledger sha: ${sha.slice(0, 12)}\u2026`
6010
+ }]
6011
+ };
6012
+ }
6013
+ if (existingSha && existingSha !== sha && !force) {
6014
+ return {
6015
+ content: [{
6016
+ type: "text",
6017
+ text: `[drift] Migration "${name2}" exists in ledger with DIFFERENT sha256.
6018
+ ledger sha: ${existingSha.slice(0, 12)}\u2026
6019
+ new sha: ${sha.slice(0, 12)}\u2026
6020
+
6021
+ This usually means the migration file was edited after it was first applied. Inspect the differences and either revert the source file or call again with \`force: true\` to overwrite the ledger entry (the SQL will be re-executed).`
6022
+ }]
6023
+ };
6024
+ }
6025
+ const appliedBy = `${authContext.apiKeyName} (${authContext.roleName})`;
6026
+ const ledgerInsert = `INSERT INTO _mcp_migrations (name, sha256, applied_by) VALUES (${dollarQuote(name2)}, ${dollarQuote(sha)}, ${dollarQuote(appliedBy)})
6027
+ ON CONFLICT (name) DO UPDATE SET sha256 = EXCLUDED.sha256, applied_at = now(), applied_by = EXCLUDED.applied_by;
6028
+ `;
6029
+ if (noTransaction) {
6030
+ const apply = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, normalised, "");
6031
+ if (apply.exitCode !== 0) {
6032
+ return {
6033
+ content: [{
6034
+ type: "text",
6035
+ text: `[failed] Migration "${name2}" did not apply cleanly (noTransaction=true, no ledger entry recorded).
6036
+ psql exit: ${apply.exitCode}
6037
+ ` + (apply.stderr ? `--- stderr ---
6038
+ ${apply.stderr}
6039
+ ` : "") + (apply.stdout ? `--- stdout ---
6040
+ ${apply.stdout}` : "")
6041
+ }]
6042
+ };
6043
+ }
6044
+ const record = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, ledgerInsert, "");
6045
+ if (record.exitCode !== 0) {
6046
+ return {
6047
+ content: [{
6048
+ type: "text",
6049
+ text: `[partial] Migration "${name2}" applied but ledger insert FAILED. Re-run with \`force: true\` after the issue is resolved.
6050
+ psql exit: ${record.exitCode}
6051
+ ` + (record.stderr ? `--- stderr ---
6052
+ ${record.stderr}
6053
+ ` : "") + (record.stdout ? `--- stdout ---
6054
+ ${record.stdout}` : "")
6055
+ }]
6056
+ };
6057
+ }
6058
+ return {
6059
+ content: [{
6060
+ type: "text",
6061
+ text: `[applied] "${name2}" on ${containerName}/${dbName} (noTransaction=true${existingSha ? ", force" : ""}).
6062
+ ledger sha: ${sha.slice(0, 12)}\u2026
6063
+ ` + (apply.stdout ? `--- output ---
6064
+ ${apply.stdout.trim()}` : "(no output)")
6065
+ }]
6066
+ };
6067
+ }
6068
+ const wrappedSql = `BEGIN;
6069
+ ${normalised}
6070
+ ${ledgerInsert}COMMIT;
6071
+ `;
6072
+ const res = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, wrappedSql, "");
6073
+ if (res.exitCode !== 0) {
6074
+ return {
6075
+ content: [{
6076
+ type: "text",
6077
+ text: `[failed] Migration "${name2}" did not apply (transaction rolled back, ledger unchanged).
6078
+ psql exit: ${res.exitCode}
6079
+ ` + (res.stderr ? `--- stderr ---
6080
+ ${res.stderr}
6081
+ ` : "") + (res.stdout ? `--- stdout ---
6082
+ ${res.stdout}` : "")
6083
+ }]
6084
+ };
6085
+ }
6086
+ return {
6087
+ content: [{
6088
+ type: "text",
6089
+ text: `[applied] "${name2}" on ${containerName}/${dbName}${existingSha ? " (forced overwrite)" : ""}.
6090
+ ledger sha: ${sha.slice(0, 12)}\u2026
6091
+ ` + (res.stdout ? `--- output ---
6092
+ ${res.stdout.trim()}` : "(no output)")
6093
+ }]
6094
+ };
6095
+ }
6096
+ case "db-list-migrations": {
6097
+ const containerName = String(a.containerName || "").replace(/[^a-zA-Z0-9._-]/g, "");
6098
+ 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";
6101
+ const limitRaw = Number(a.limit);
6102
+ 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
6106
+ FROM _mcp_migrations
6107
+ ORDER BY applied_at DESC
6108
+ LIMIT ${limit};
6109
+ `;
6110
+ const res = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, sqlText, "");
6111
+ if (res.exitCode !== 0) {
6112
+ if (/relation "_mcp_migrations" does not exist/.test(res.stderr) || /relation "_mcp_migrations" does not exist/.test(res.stdout)) {
6113
+ return { content: [{ type: "text", text: `No \`_mcp_migrations\` ledger on ${containerName}/${dbName} yet. Apply your first migration with \`db-apply-migration\` to create it.` }] };
6114
+ }
6115
+ return { content: [{ type: "text", text: `Error (exit ${res.exitCode}): ${res.stderr || res.stdout}` }] };
6116
+ }
6117
+ const output = res.stdout.trim();
6118
+ return { content: [{ type: "text", text: output || `Ledger exists on ${containerName}/${dbName} but is empty.` }] };
4949
6119
  }
4950
6120
  // ----- Env Config -----
4951
6121
  case "env-list": {
4952
- let query = supabase.from("env_config").select("id, app_name, environment, description, updated_at, release_profile_stage_id").order("app_name").order("environment");
6122
+ let stageFilterIds = null;
4953
6123
  if (a.releaseProfile) {
4954
6124
  const { stageIds: stageIds2 } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
4955
- query = query.in("release_profile_stage_id", stageIds2);
6125
+ stageFilterIds = stageIds2;
4956
6126
  }
4957
- const { data, error } = await query;
4958
- if (error) throw new Error(error.message);
4959
- const stageIds = (data || []).map((e) => e.release_profile_stage_id).filter(Boolean);
6127
+ const data = stageFilterIds ? await db.execute(sql`
6128
+ SELECT id, app_name, environment, description, updated_at, release_profile_stage_id
6129
+ FROM env_config
6130
+ WHERE release_profile_stage_id = ANY(${stageFilterIds}::uuid[])
6131
+ ORDER BY app_name, environment
6132
+ `) : await db.execute(sql`
6133
+ SELECT id, app_name, environment, description, updated_at, release_profile_stage_id
6134
+ FROM env_config
6135
+ ORDER BY app_name, environment
6136
+ `);
6137
+ const stageIds = data.map((e) => e.release_profile_stage_id).filter((v) => Boolean(v));
4960
6138
  const profileNames = await getProfileNamesForStageIds(stageIds);
4961
- const lines = (data || []).map((e) => {
6139
+ const lines = data.map((e) => {
4962
6140
  const profile = e.release_profile_stage_id ? profileNames[e.release_profile_stage_id] || "unknown" : "unlinked";
4963
6141
  return `${e.app_name}/${e.environment} [${profile}] (updated: ${e.updated_at})`;
4964
6142
  });
4965
6143
  return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No environment configs stored" }] };
4966
6144
  }
4967
6145
  case "env-get": {
4968
- let query = supabase.from("env_config").select("env_data_encrypted, release_profile_stage_id").eq("app_name", String(a.appName)).eq("environment", String(a.environment));
6146
+ const appName = String(a.appName);
6147
+ const environment = String(a.environment);
6148
+ let stageFilterIds = null;
4969
6149
  if (a.releaseProfile) {
4970
6150
  const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
4971
- query = query.in("release_profile_stage_id", stageIds);
6151
+ stageFilterIds = stageIds;
4972
6152
  }
4973
- const { data, error } = await query;
4974
- if (error) throw new Error(`Env config query failed: ${error.message}`);
4975
- if (!data || data.length === 0) {
6153
+ const data = stageFilterIds ? await db.execute(sql`
6154
+ SELECT env_data_encrypted, release_profile_stage_id
6155
+ FROM env_config
6156
+ WHERE app_name = ${appName}
6157
+ AND environment = ${environment}
6158
+ AND release_profile_stage_id = ANY(${stageFilterIds}::uuid[])
6159
+ `) : await db.execute(sql`
6160
+ SELECT env_data_encrypted, release_profile_stage_id
6161
+ FROM env_config
6162
+ WHERE app_name = ${appName}
6163
+ AND environment = ${environment}
6164
+ `);
6165
+ if (data.length === 0) {
4976
6166
  throw new Error(`Env config not found: ${a.appName}/${a.environment}${a.releaseProfile ? ` (profile: ${a.releaseProfile})` : ""}`);
4977
6167
  }
4978
6168
  if (data.length > 1) {
4979
- const stageIds = data.map((r) => r.release_profile_stage_id).filter(Boolean);
6169
+ const stageIds = data.map((r) => r.release_profile_stage_id).filter((v) => Boolean(v));
4980
6170
  const profileNames = await getProfileNamesForStageIds(stageIds);
4981
6171
  const names = [...new Set(Object.values(profileNames))].join(", ");
4982
6172
  throw new Error(
@@ -4995,45 +6185,56 @@ GO
4995
6185
  const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
4996
6186
  resolvedStageIds = stageIds;
4997
6187
  }
4998
- let existQuery = supabase.from("env_config").select("id, release_profile_stage_id").eq("app_name", appName).eq("environment", environment);
4999
- if (resolvedStageIds) {
5000
- existQuery = existQuery.in("release_profile_stage_id", resolvedStageIds);
5001
- }
5002
- const { data: existingRows, error: existErr } = await existQuery;
5003
- if (existErr) throw new Error(`Lookup failed: ${existErr.message}`);
5004
- if (existingRows && existingRows.length > 1 && !resolvedStageIds) {
5005
- const stageIds = existingRows.map((r) => r.release_profile_stage_id).filter(Boolean);
6188
+ const existingRows = resolvedStageIds ? await db.execute(sql`
6189
+ SELECT id, release_profile_stage_id
6190
+ FROM env_config
6191
+ WHERE app_name = ${appName}
6192
+ AND environment = ${environment}
6193
+ AND release_profile_stage_id = ANY(${resolvedStageIds}::uuid[])
6194
+ `) : await db.execute(sql`
6195
+ SELECT id, release_profile_stage_id
6196
+ FROM env_config
6197
+ WHERE app_name = ${appName}
6198
+ AND environment = ${environment}
6199
+ `);
6200
+ if (existingRows.length > 1 && !resolvedStageIds) {
6201
+ const stageIds = existingRows.map((r) => r.release_profile_stage_id).filter((v) => Boolean(v));
5006
6202
  const profileNames = await getProfileNamesForStageIds(stageIds);
5007
6203
  const names = [...new Set(Object.values(profileNames))].join(", ");
5008
6204
  throw new Error(
5009
6205
  `Multiple env configs found for ${appName}/${environment} across profiles: ${names}. Pass releaseProfile parameter to select one.`
5010
6206
  );
5011
6207
  }
5012
- const existing = existingRows?.[0] ?? null;
6208
+ const existing = existingRows[0] ?? null;
6209
+ const description = a.description ? String(a.description) : null;
6210
+ const updatedBy = authContext.userId;
5013
6211
  let saveMsg;
5014
6212
  if (existing) {
5015
- const { error } = await supabase.from("env_config").update({
5016
- env_data_encrypted: encrypted,
5017
- description: a.description ? String(a.description) : void 0,
5018
- updated_by: authContext.userId,
5019
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
5020
- }).eq("id", existing.id);
5021
- if (error) throw new Error(error.message);
6213
+ await db.execute(sql`
6214
+ UPDATE env_config
6215
+ SET env_data_encrypted = ${encrypted},
6216
+ description = COALESCE(${description}, description),
6217
+ updated_by = ${updatedBy},
6218
+ updated_at = ${(/* @__PURE__ */ new Date()).toISOString()}
6219
+ WHERE id = ${existing.id}
6220
+ `);
5022
6221
  saveMsg = `Updated env config: ${appName}/${environment}`;
5023
6222
  } else {
5024
- const insertData = {
5025
- app_name: appName,
5026
- environment,
5027
- env_data_encrypted: encrypted,
5028
- description: a.description ? String(a.description) : null,
5029
- created_by: authContext.userId,
5030
- updated_by: authContext.userId
5031
- };
5032
- if (resolvedStageIds?.[0]) {
5033
- insertData.release_profile_stage_id = resolvedStageIds[0];
5034
- }
5035
- const { error } = await supabase.from("env_config").insert(insertData);
5036
- if (error) throw new Error(error.message);
6223
+ const stageId = resolvedStageIds?.[0] ?? null;
6224
+ await db.execute(sql`
6225
+ INSERT INTO env_config (
6226
+ app_name, environment, env_data_encrypted, description,
6227
+ created_by, updated_by, release_profile_stage_id
6228
+ ) VALUES (
6229
+ ${appName},
6230
+ ${environment},
6231
+ ${encrypted},
6232
+ ${description},
6233
+ ${updatedBy},
6234
+ ${updatedBy},
6235
+ ${stageId}
6236
+ )
6237
+ `);
5037
6238
  saveMsg = `Stored env config: ${appName}/${environment}`;
5038
6239
  }
5039
6240
  const syncStageId = existing?.release_profile_stage_id ?? resolvedStageIds?.[0];
@@ -5300,7 +6501,10 @@ ${lines.join("\n")}` }] };
5300
6501
  return handleTriggerTool(name, a, { sshExec, getServerConnection });
5301
6502
  }
5302
6503
  if (VERCEL_TOOL_NAMES.has(name)) {
5303
- return handleVercelTool(name, a, { supabase, decrypt });
6504
+ return handleVercelTool(name, a, { db, decrypt });
6505
+ }
6506
+ if (REPO_TOOL_NAMES.has(name)) {
6507
+ return handleRepoTool(name, a, { db, sshExec, getServerConnection });
5304
6508
  }
5305
6509
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
5306
6510
  }