@mgsoftwarebv/mg-dashboard-mcp 6.0.1 → 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 +1395 -163
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
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 {
|
|
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
|
|
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 "${
|
|
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
|
|
368
|
-
const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${
|
|
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
|
|
451
|
-
const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${
|
|
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
|
|
938
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
1171
|
-
).
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
|
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 (!
|
|
1274
|
-
console.error("
|
|
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
|
-
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
|
1438
|
-
|
|
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
|
|
1447
|
-
|
|
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.
|
|
1452
|
-
const roleDefaults = userData.
|
|
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
|
|
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
|
|
1600
|
-
|
|
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
|
|
1613
|
-
|
|
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
|
|
1622
|
-
|
|
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.
|
|
1627
|
-
const roleDefaults = userData.
|
|
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
|
-
|
|
1637
|
-
|
|
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
|
|
1661
|
-
|
|
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
|
|
1664
|
-
|
|
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
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
|
1679
|
-
|
|
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
|
|
1682
|
-
|
|
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
|
|
1777
|
-
|
|
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
|
|
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
|
|
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
|
|
1797
|
-
|
|
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
|
|
1881
|
-
|
|
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
|
|
1896
|
-
|
|
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
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
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,
|
|
@@ -3522,6 +4398,28 @@ function formatDnsDiff(domain, before, after, change) {
|
|
|
3522
4398
|
lines.push("", "Re-run without `dryRun: true` to apply.");
|
|
3523
4399
|
return lines.join("\n");
|
|
3524
4400
|
}
|
|
4401
|
+
function describeDnsCandidates(records, type, name, attemptedValue) {
|
|
4402
|
+
const sameTypeAndName = records.filter((r) => r.type === type && r.name === name);
|
|
4403
|
+
const fmt = (r) => ` - ${r.type.padEnd(6)} ${r.name.padEnd(30)} ttl=${String(r.ttl).padEnd(5)} ${r.value}`;
|
|
4404
|
+
if (sameTypeAndName.length > 0) {
|
|
4405
|
+
const lines = ["Current records with this type + name (use one of these values verbatim):"];
|
|
4406
|
+
for (const r of sameTypeAndName.slice(0, 10)) lines.push(fmt(r));
|
|
4407
|
+
if (sameTypeAndName.length > 10) lines.push(` ...and ${sameTypeAndName.length - 10} more`);
|
|
4408
|
+
lines.push(`(attempted value: ${attemptedValue})`);
|
|
4409
|
+
return lines.join("\n");
|
|
4410
|
+
}
|
|
4411
|
+
const sameName = records.filter((r) => r.name === name);
|
|
4412
|
+
if (sameName.length > 0) {
|
|
4413
|
+
const types = [...new Set(sameName.map((r) => r.type))].sort().join(", ");
|
|
4414
|
+
const lines = [
|
|
4415
|
+
`No ${type} records exist for "${name}", but other records do (types: ${types}). Sample:`
|
|
4416
|
+
];
|
|
4417
|
+
for (const r of sameName.slice(0, 10)) lines.push(fmt(r));
|
|
4418
|
+
if (sameName.length > 10) lines.push(` ...and ${sameName.length - 10} more`);
|
|
4419
|
+
return lines.join("\n");
|
|
4420
|
+
}
|
|
4421
|
+
return `No records found at all for name "${name}". Run dns-list to inspect the zone.`;
|
|
4422
|
+
}
|
|
3525
4423
|
async function mijnhostFetch(path, options = {}) {
|
|
3526
4424
|
const key = requireMijnhostApiKey();
|
|
3527
4425
|
const res = await fetch(`${MIJNHOST_BASE_URL}${path}`, {
|
|
@@ -3741,48 +4639,92 @@ var TOOLS = [
|
|
|
3741
4639
|
},
|
|
3742
4640
|
{
|
|
3743
4641
|
name: "db-discover",
|
|
3744
|
-
description:
|
|
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"] }`.',
|
|
3745
4643
|
inputSchema: {
|
|
3746
4644
|
type: "object",
|
|
3747
4645
|
properties: {
|
|
3748
|
-
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
|
+
}
|
|
3749
4652
|
},
|
|
3750
4653
|
required: ["serverId"]
|
|
3751
4654
|
}
|
|
3752
4655
|
},
|
|
3753
4656
|
{
|
|
3754
4657
|
name: "db-tables",
|
|
3755
|
-
description: "List
|
|
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.",
|
|
3756
4659
|
inputSchema: {
|
|
3757
4660
|
type: "object",
|
|
3758
4661
|
properties: {
|
|
3759
4662
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
3760
|
-
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")' }
|
|
3761
4667
|
},
|
|
3762
|
-
required: ["serverId"
|
|
4668
|
+
required: ["serverId"]
|
|
3763
4669
|
}
|
|
3764
4670
|
},
|
|
3765
4671
|
{
|
|
3766
4672
|
name: "db-query",
|
|
3767
|
-
description: 'Execute
|
|
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.',
|
|
3768
4674
|
inputSchema: {
|
|
3769
4675
|
type: "object",
|
|
3770
4676
|
properties: {
|
|
3771
4677
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
3772
|
-
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." },
|
|
3773
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.' },
|
|
3774
4680
|
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
|
|
3775
|
-
containerName: { type: "string", description: 'Docker container running the DB server (e.g. "
|
|
4681
|
+
containerName: { type: "string", description: 'Docker container running the DB server (e.g. "refront-postgres-vanilla", "supabase-db"). Activates direct-query mode.' },
|
|
3776
4682
|
engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
|
|
3777
4683
|
dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
|
|
3778
4684
|
dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
|
|
3779
4685
|
dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." },
|
|
3780
4686
|
maxRows: { type: "number", description: "Auto-inject LIMIT for unbounded SELECTs. Default 1000, max 10000, set 0 to disable." },
|
|
3781
|
-
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).' }
|
|
3782
4689
|
},
|
|
3783
4690
|
required: ["serverId"]
|
|
3784
4691
|
}
|
|
3785
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
|
+
},
|
|
3786
4728
|
{
|
|
3787
4729
|
name: "env-list",
|
|
3788
4730
|
description: "List all stored environment configurations with their release profile names.",
|
|
@@ -3878,9 +4820,11 @@ var TOOLS = [
|
|
|
3878
4820
|
// ----- Trigger.dev -----
|
|
3879
4821
|
...TRIGGER_TOOLS,
|
|
3880
4822
|
// ----- Vercel -----
|
|
3881
|
-
...VERCEL_TOOLS
|
|
4823
|
+
...VERCEL_TOOLS,
|
|
4824
|
+
// ----- Repo reference -----
|
|
4825
|
+
...REPO_TOOLS
|
|
3882
4826
|
];
|
|
3883
|
-
var MCP_VERSION = "6.0
|
|
4827
|
+
var MCP_VERSION = "6.1.0";
|
|
3884
4828
|
async function handleListTools() {
|
|
3885
4829
|
if (!authContext) return { tools: TOOLS };
|
|
3886
4830
|
const accessible = TOOLS.filter((tool) => {
|
|
@@ -3945,14 +4889,18 @@ async function executeToolCall(name, a, _serverId) {
|
|
|
3945
4889
|
switch (name) {
|
|
3946
4890
|
// ----- Servers -----
|
|
3947
4891
|
case "list-servers": {
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
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
|
+
`);
|
|
3954
4902
|
const includeStats = a.includeStats === true;
|
|
3955
|
-
const servers = data
|
|
4903
|
+
const servers = data;
|
|
3956
4904
|
rememberServers(servers.map((s) => ({ id: s.id, name: s.name })));
|
|
3957
4905
|
let statsByServer = /* @__PURE__ */ new Map();
|
|
3958
4906
|
if (includeStats && servers.length > 0) {
|
|
@@ -4080,8 +5028,10 @@ ${contextHints.join("\n")}` : out }] };
|
|
|
4080
5028
|
const last = iterations[iterations.length - 1].result;
|
|
4081
5029
|
let serverName2 = serverId;
|
|
4082
5030
|
try {
|
|
4083
|
-
const
|
|
4084
|
-
|
|
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;
|
|
4085
5035
|
} catch {
|
|
4086
5036
|
}
|
|
4087
5037
|
return { serverId, serverName: serverName2, os, shell, result: last, iterations };
|
|
@@ -4096,8 +5046,10 @@ ${contextHints.join("\n")}` : out }] };
|
|
|
4096
5046
|
}
|
|
4097
5047
|
let serverName = serverId;
|
|
4098
5048
|
try {
|
|
4099
|
-
const
|
|
4100
|
-
|
|
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;
|
|
4101
5053
|
} catch {
|
|
4102
5054
|
}
|
|
4103
5055
|
return { serverId, serverName, os, shell, result, pipelineSegments };
|
|
@@ -4829,19 +5781,66 @@ ${trail.join("\n")}` }] };
|
|
|
4829
5781
|
// ----- Database -----
|
|
4830
5782
|
case "db-discover": {
|
|
4831
5783
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
4832
|
-
const
|
|
4833
|
-
|
|
4834
|
-
|
|
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
|
+
}
|
|
4835
5799
|
}
|
|
4836
|
-
|
|
4837
|
-
(
|
|
4838
|
-
|
|
4839
|
-
|
|
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") }] };
|
|
4840
5815
|
}
|
|
4841
5816
|
case "db-tables": {
|
|
4842
5817
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
4843
|
-
const
|
|
4844
|
-
|
|
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);
|
|
4845
5844
|
return { content: [{ type: "text", text: output || "No tables found" }] };
|
|
4846
5845
|
}
|
|
4847
5846
|
case "db-query": {
|
|
@@ -4859,7 +5858,24 @@ ${trail.join("\n")}` }] };
|
|
|
4859
5858
|
rawQuery = String(a.query ?? "").trim();
|
|
4860
5859
|
if (!rawQuery) return { content: [{ type: "text", text: "Error: query (or describe) is required" }] };
|
|
4861
5860
|
}
|
|
4862
|
-
|
|
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
|
+
}
|
|
4863
5879
|
let query = rawQuery.replace(/;\s*$/, "");
|
|
4864
5880
|
let appliedLimit = false;
|
|
4865
5881
|
if (explainMode && !describeArg) {
|
|
@@ -4878,12 +5894,13 @@ LIMIT ${maxRows + 1}`;
|
|
|
4878
5894
|
appliedLimit = true;
|
|
4879
5895
|
}
|
|
4880
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." : "";
|
|
4881
5898
|
if (!containerName && engine === "mysql") {
|
|
4882
5899
|
if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
|
|
4883
5900
|
const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
|
|
4884
5901
|
const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
|
|
4885
5902
|
const footer2 = formatDbQueryFooter(output2, appliedLimit, maxRows, explainMode);
|
|
4886
|
-
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 }] };
|
|
4887
5904
|
}
|
|
4888
5905
|
if (!containerName) {
|
|
4889
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")` }] };
|
|
@@ -4895,8 +5912,8 @@ LIMIT ${maxRows + 1}`;
|
|
|
4895
5912
|
let stdinPayload;
|
|
4896
5913
|
if (engine === "postgres") {
|
|
4897
5914
|
const user = dbUser || "postgres";
|
|
4898
|
-
const
|
|
4899
|
-
cmd = `docker exec -i ${containerName} psql -U ${user} -d ${
|
|
5915
|
+
const db2 = dbName || "postgres";
|
|
5916
|
+
cmd = `docker exec -i ${containerName} psql -U ${user} -d ${db2} -P pager=off`;
|
|
4900
5917
|
stdinPayload = query.endsWith(";") ? query : `${query};`;
|
|
4901
5918
|
} else if (engine === "mssql") {
|
|
4902
5919
|
const user = dbUser || "sa";
|
|
@@ -4911,10 +5928,10 @@ GO
|
|
|
4911
5928
|
`;
|
|
4912
5929
|
} else {
|
|
4913
5930
|
const user = dbUser || "root";
|
|
4914
|
-
const
|
|
5931
|
+
const db2 = dbName ? ` ${dbName}` : "";
|
|
4915
5932
|
const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
|
|
4916
5933
|
const passArg = dbPass ? `-p${posixQuote(dbPass)} ` : "";
|
|
4917
|
-
cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${
|
|
5934
|
+
cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${db2}`;
|
|
4918
5935
|
stdinPayload = query.endsWith(";") ? query : `${query};`;
|
|
4919
5936
|
}
|
|
4920
5937
|
const result = await sshExec(conn, cmd, proxy, { stdin: stdinPayload });
|
|
@@ -4923,38 +5940,233 @@ GO
|
|
|
4923
5940
|
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
|
|
4924
5941
|
}
|
|
4925
5942
|
const footer = formatDbQueryFooter(output, appliedLimit, maxRows, explainMode);
|
|
4926
|
-
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.` }] };
|
|
4927
6119
|
}
|
|
4928
6120
|
// ----- Env Config -----
|
|
4929
6121
|
case "env-list": {
|
|
4930
|
-
let
|
|
6122
|
+
let stageFilterIds = null;
|
|
4931
6123
|
if (a.releaseProfile) {
|
|
4932
6124
|
const { stageIds: stageIds2 } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
|
|
4933
|
-
|
|
6125
|
+
stageFilterIds = stageIds2;
|
|
4934
6126
|
}
|
|
4935
|
-
const
|
|
4936
|
-
|
|
4937
|
-
|
|
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));
|
|
4938
6138
|
const profileNames = await getProfileNamesForStageIds(stageIds);
|
|
4939
|
-
const lines =
|
|
6139
|
+
const lines = data.map((e) => {
|
|
4940
6140
|
const profile = e.release_profile_stage_id ? profileNames[e.release_profile_stage_id] || "unknown" : "unlinked";
|
|
4941
6141
|
return `${e.app_name}/${e.environment} [${profile}] (updated: ${e.updated_at})`;
|
|
4942
6142
|
});
|
|
4943
6143
|
return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No environment configs stored" }] };
|
|
4944
6144
|
}
|
|
4945
6145
|
case "env-get": {
|
|
4946
|
-
|
|
6146
|
+
const appName = String(a.appName);
|
|
6147
|
+
const environment = String(a.environment);
|
|
6148
|
+
let stageFilterIds = null;
|
|
4947
6149
|
if (a.releaseProfile) {
|
|
4948
6150
|
const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
|
|
4949
|
-
|
|
6151
|
+
stageFilterIds = stageIds;
|
|
4950
6152
|
}
|
|
4951
|
-
const
|
|
4952
|
-
|
|
4953
|
-
|
|
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) {
|
|
4954
6166
|
throw new Error(`Env config not found: ${a.appName}/${a.environment}${a.releaseProfile ? ` (profile: ${a.releaseProfile})` : ""}`);
|
|
4955
6167
|
}
|
|
4956
6168
|
if (data.length > 1) {
|
|
4957
|
-
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));
|
|
4958
6170
|
const profileNames = await getProfileNamesForStageIds(stageIds);
|
|
4959
6171
|
const names = [...new Set(Object.values(profileNames))].join(", ");
|
|
4960
6172
|
throw new Error(
|
|
@@ -4973,45 +6185,56 @@ GO
|
|
|
4973
6185
|
const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
|
|
4974
6186
|
resolvedStageIds = stageIds;
|
|
4975
6187
|
}
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
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));
|
|
4984
6202
|
const profileNames = await getProfileNamesForStageIds(stageIds);
|
|
4985
6203
|
const names = [...new Set(Object.values(profileNames))].join(", ");
|
|
4986
6204
|
throw new Error(
|
|
4987
6205
|
`Multiple env configs found for ${appName}/${environment} across profiles: ${names}. Pass releaseProfile parameter to select one.`
|
|
4988
6206
|
);
|
|
4989
6207
|
}
|
|
4990
|
-
const existing = existingRows
|
|
6208
|
+
const existing = existingRows[0] ?? null;
|
|
6209
|
+
const description = a.description ? String(a.description) : null;
|
|
6210
|
+
const updatedBy = authContext.userId;
|
|
4991
6211
|
let saveMsg;
|
|
4992
6212
|
if (existing) {
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
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
|
+
`);
|
|
5000
6221
|
saveMsg = `Updated env config: ${appName}/${environment}`;
|
|
5001
6222
|
} else {
|
|
5002
|
-
const
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
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
|
+
`);
|
|
5015
6238
|
saveMsg = `Stored env config: ${appName}/${environment}`;
|
|
5016
6239
|
}
|
|
5017
6240
|
const syncStageId = existing?.release_profile_stage_id ?? resolvedStageIds?.[0];
|
|
@@ -5227,7 +6450,10 @@ ${lines.join("\n")}` }] };
|
|
|
5227
6450
|
(r) => r.type === type && r.name === dnsName && r.value === oldValue
|
|
5228
6451
|
);
|
|
5229
6452
|
if (idx === -1) {
|
|
5230
|
-
throw new Error(
|
|
6453
|
+
throw new Error(
|
|
6454
|
+
`No matching DNS record found: ${type} ${dnsName} = ${oldValue}
|
|
6455
|
+
` + describeDnsCandidates(current.data.records, type, dnsName, oldValue)
|
|
6456
|
+
);
|
|
5231
6457
|
}
|
|
5232
6458
|
const updated = [...current.data.records];
|
|
5233
6459
|
const before = updated[idx];
|
|
@@ -5251,7 +6477,10 @@ ${lines.join("\n")}` }] };
|
|
|
5251
6477
|
(r) => !(r.type === type && r.name === dnsName && r.value === value)
|
|
5252
6478
|
);
|
|
5253
6479
|
if (removed.length === 0) {
|
|
5254
|
-
throw new Error(
|
|
6480
|
+
throw new Error(
|
|
6481
|
+
`No matching DNS record found: ${type} ${dnsName} = ${value}
|
|
6482
|
+
` + describeDnsCandidates(current.data.records, type, dnsName, value)
|
|
6483
|
+
);
|
|
5255
6484
|
}
|
|
5256
6485
|
nextRecords = remaining;
|
|
5257
6486
|
diffArgs = { verb: "delete", removed };
|
|
@@ -5272,7 +6501,10 @@ ${lines.join("\n")}` }] };
|
|
|
5272
6501
|
return handleTriggerTool(name, a, { sshExec, getServerConnection });
|
|
5273
6502
|
}
|
|
5274
6503
|
if (VERCEL_TOOL_NAMES.has(name)) {
|
|
5275
|
-
return handleVercelTool(name, a, {
|
|
6504
|
+
return handleVercelTool(name, a, { db, decrypt });
|
|
6505
|
+
}
|
|
6506
|
+
if (REPO_TOOL_NAMES.has(name)) {
|
|
6507
|
+
return handleRepoTool(name, a, { db, sshExec, getServerConnection });
|
|
5276
6508
|
}
|
|
5277
6509
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
|
5278
6510
|
}
|