@mgsoftwarebv/mg-dashboard-mcp 6.0.2 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1365 -161
- 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,
|
|
@@ -3763,48 +4639,92 @@ var TOOLS = [
|
|
|
3763
4639
|
},
|
|
3764
4640
|
{
|
|
3765
4641
|
name: "db-discover",
|
|
3766
|
-
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"] }`.',
|
|
3767
4643
|
inputSchema: {
|
|
3768
4644
|
type: "object",
|
|
3769
4645
|
properties: {
|
|
3770
|
-
serverId: { type: "string", description: "UUID of the SSH server" }
|
|
4646
|
+
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
4647
|
+
include: {
|
|
4648
|
+
type: "array",
|
|
4649
|
+
description: 'Which discovery modes to run. Defaults to `["www"]` for back-compat. Add `"postgres-containers"` to enumerate vanilla Postgres Docker containers with their credentials.',
|
|
4650
|
+
items: { type: "string", enum: ["www", "postgres-containers"] }
|
|
4651
|
+
}
|
|
3771
4652
|
},
|
|
3772
4653
|
required: ["serverId"]
|
|
3773
4654
|
}
|
|
3774
4655
|
},
|
|
3775
4656
|
{
|
|
3776
4657
|
name: "db-tables",
|
|
3777
|
-
description: "List
|
|
4658
|
+
description: "List tables with row counts + sizes. Two routing modes:\n- MySQL via `/var/www` autodiscover: pass `sitePath`. Credentials are read from wp-config.php / parameters.php / .env.\n- Postgres container: pass `containerName` (and optionally `dbName` / `dbUser`, defaulting to `postgres`). Returns one row per user table sorted by total size, with estimated row count from pg_class.reltuples.",
|
|
3778
4659
|
inputSchema: {
|
|
3779
4660
|
type: "object",
|
|
3780
4661
|
properties: {
|
|
3781
4662
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
3782
|
-
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com)" }
|
|
4663
|
+
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). MySQL autodiscover mode." },
|
|
4664
|
+
containerName: { type: "string", description: "Postgres container name. Activates direct Postgres mode." },
|
|
4665
|
+
dbName: { type: "string", description: 'Database name (containerName mode, defaults to "postgres")' },
|
|
4666
|
+
dbUser: { type: "string", description: 'Database user (containerName mode, defaults to "postgres")' }
|
|
3783
4667
|
},
|
|
3784
|
-
required: ["serverId"
|
|
4668
|
+
required: ["serverId"]
|
|
3785
4669
|
}
|
|
3786
4670
|
},
|
|
3787
4671
|
{
|
|
3788
4672
|
name: "db-query",
|
|
3789
|
-
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.',
|
|
3790
4674
|
inputSchema: {
|
|
3791
4675
|
type: "object",
|
|
3792
4676
|
properties: {
|
|
3793
4677
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
3794
|
-
query: { type: "string", description: "SQL query to execute (ignored when describe is set)" },
|
|
4678
|
+
query: { type: "string", description: "SQL query to execute (ignored when describe is set). Multiple statements separated by `;` are piped together to the client." },
|
|
3795
4679
|
describe: { type: "string", description: 'Schema introspection shortcut. Pass a table name for columns + indexes, or "*" to list all tables. Works for mysql / postgres / mssql.' },
|
|
3796
4680
|
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
|
|
3797
|
-
containerName: { type: "string", description: 'Docker container running the DB server (e.g. "
|
|
4681
|
+
containerName: { type: "string", description: 'Docker container running the DB server (e.g. "refront-postgres-vanilla", "supabase-db"). Activates direct-query mode.' },
|
|
3798
4682
|
engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
|
|
3799
4683
|
dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
|
|
3800
4684
|
dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
|
|
3801
4685
|
dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." },
|
|
3802
4686
|
maxRows: { type: "number", description: "Auto-inject LIMIT for unbounded SELECTs. Default 1000, max 10000, set 0 to disable." },
|
|
3803
|
-
explain: { type: "boolean", description: "Prepend EXPLAIN (postgres/mysql) or SHOWPLAN (mssql) instead of executing. Useful for slow-query diagnosis." }
|
|
4687
|
+
explain: { type: "boolean", description: "Prepend EXPLAIN (postgres/mysql) or SHOWPLAN (mssql) instead of executing. Useful for slow-query diagnosis." },
|
|
4688
|
+
allowDestructive: { type: "string", description: 'Escape hatch for ad-hoc DROP/TRUNCATE/ALTER\u2026DROP/naked DELETE. Pass the literal string "yes-i-understand-this-is-not-logged". For schema migrations prefer db-apply-migration (audit-logged, idempotent).' }
|
|
3804
4689
|
},
|
|
3805
4690
|
required: ["serverId"]
|
|
3806
4691
|
}
|
|
3807
4692
|
},
|
|
4693
|
+
{
|
|
4694
|
+
name: "db-apply-migration",
|
|
4695
|
+
description: 'Apply a SQL migration to a Postgres container and record it in an MCP-managed ledger table (`_mcp_migrations`) so re-runs are idempotent. Use this for ALL schema changes (CREATE/ALTER/DROP/TRUNCATE) \u2014 it allows DDL that `db-query` blocks, and gives you audit + drift detection in exchange.\n\nLedger schema (auto-created on first call): `_mcp_migrations(name TEXT PRIMARY KEY, sha256 TEXT, applied_at TIMESTAMPTZ, applied_by TEXT)`.\n\nBehaviour:\n- If `name` is already in the ledger AND the sha256 of the supplied SQL matches \u2192 no-op, returns "already applied".\n- If `name` is already in the ledger with a DIFFERENT sha256 \u2192 fails loudly ("drift detected"). Pass `force: true` to overwrite (logged).\n- Otherwise the SQL is wrapped in BEGIN/COMMIT and applied, then a row is inserted into `_mcp_migrations`.\n\nPass `noTransaction: true` for statements that cannot run inside a transaction (`CREATE INDEX CONCURRENTLY`, `ALTER TYPE \u2026 ADD VALUE`, `VACUUM`, etc.). In that mode the user SQL runs first and the ledger is recorded in a separate follow-up call.\n\nSQL source: provide ONE of `sql` (inline string), `localFile` (path on this machine, read and piped via stdin), or `remoteFile` (absolute path on the SSH server, read with `cat` first).\n\nExample: `{ serverId, containerName: "refront-postgres-vanilla", dbName: "main", name: "20260517120000_add_unified_classification", localFile: "./migrations/20260517120000_add_unified_classification.sql" }`.',
|
|
4696
|
+
inputSchema: {
|
|
4697
|
+
type: "object",
|
|
4698
|
+
properties: {
|
|
4699
|
+
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
4700
|
+
containerName: { type: "string", description: 'Postgres container name (e.g. "refront-postgres-vanilla")' },
|
|
4701
|
+
dbName: { type: "string", description: 'Database (defaults to "postgres" \u2014 set explicitly to your app DB)' },
|
|
4702
|
+
dbUser: { type: "string", description: 'User (defaults to "postgres")' },
|
|
4703
|
+
name: { type: "string", description: "Unique migration name (recommended format: `YYYYMMDDhhmmss_description`)" },
|
|
4704
|
+
sql: { type: "string", description: "Inline SQL string (mutually exclusive with localFile / remoteFile)" },
|
|
4705
|
+
localFile: { type: "string", description: "Path on this machine to a .sql file (read here, streamed via stdin)" },
|
|
4706
|
+
remoteFile: { type: "string", description: "Absolute path of a .sql file already on the server (read with `cat`)" },
|
|
4707
|
+
noTransaction: { type: "boolean", description: "Skip the implicit BEGIN/COMMIT wrapper. Required for CONCURRENTLY / ALTER TYPE ADD VALUE / VACUUM. Default false." },
|
|
4708
|
+
force: { type: "boolean", description: "Re-apply even when the ledger says it ran with a different sha256. Use after a deliberate edit; logged in the response." }
|
|
4709
|
+
},
|
|
4710
|
+
required: ["serverId", "containerName", "name"]
|
|
4711
|
+
}
|
|
4712
|
+
},
|
|
4713
|
+
{
|
|
4714
|
+
name: "db-list-migrations",
|
|
4715
|
+
description: 'List entries from the `_mcp_migrations` ledger on a Postgres container. Answers "what migrations have already been applied here?" without having to write SQL. Returns name, sha256 (truncated), applied_at, applied_by, ordered by most recent first. If the ledger table does not exist yet (no migrations applied via db-apply-migration), returns an empty list.',
|
|
4716
|
+
inputSchema: {
|
|
4717
|
+
type: "object",
|
|
4718
|
+
properties: {
|
|
4719
|
+
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
4720
|
+
containerName: { type: "string", description: "Postgres container name" },
|
|
4721
|
+
dbName: { type: "string", description: 'Database (defaults to "postgres")' },
|
|
4722
|
+
dbUser: { type: "string", description: 'User (defaults to "postgres")' },
|
|
4723
|
+
limit: { type: "number", description: "Max number of entries (default 50, max 500)" }
|
|
4724
|
+
},
|
|
4725
|
+
required: ["serverId", "containerName"]
|
|
4726
|
+
}
|
|
4727
|
+
},
|
|
3808
4728
|
{
|
|
3809
4729
|
name: "env-list",
|
|
3810
4730
|
description: "List all stored environment configurations with their release profile names.",
|
|
@@ -3900,9 +4820,11 @@ var TOOLS = [
|
|
|
3900
4820
|
// ----- Trigger.dev -----
|
|
3901
4821
|
...TRIGGER_TOOLS,
|
|
3902
4822
|
// ----- Vercel -----
|
|
3903
|
-
...VERCEL_TOOLS
|
|
4823
|
+
...VERCEL_TOOLS,
|
|
4824
|
+
// ----- Repo reference -----
|
|
4825
|
+
...REPO_TOOLS
|
|
3904
4826
|
];
|
|
3905
|
-
var MCP_VERSION = "6.0
|
|
4827
|
+
var MCP_VERSION = "6.1.0";
|
|
3906
4828
|
async function handleListTools() {
|
|
3907
4829
|
if (!authContext) return { tools: TOOLS };
|
|
3908
4830
|
const accessible = TOOLS.filter((tool) => {
|
|
@@ -3967,14 +4889,18 @@ async function executeToolCall(name, a, _serverId) {
|
|
|
3967
4889
|
switch (name) {
|
|
3968
4890
|
// ----- Servers -----
|
|
3969
4891
|
case "list-servers": {
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
4892
|
+
const data = ctx.allowedServerIds !== null ? await db.execute(sql`
|
|
4893
|
+
SELECT id, name, hostname, port, username, tags, hosted_by, os_type, created_at
|
|
4894
|
+
FROM ssh_server
|
|
4895
|
+
WHERE id = ANY(${ctx.allowedServerIds}::uuid[])
|
|
4896
|
+
ORDER BY name
|
|
4897
|
+
`) : await db.execute(sql`
|
|
4898
|
+
SELECT id, name, hostname, port, username, tags, hosted_by, os_type, created_at
|
|
4899
|
+
FROM ssh_server
|
|
4900
|
+
ORDER BY name
|
|
4901
|
+
`);
|
|
3976
4902
|
const includeStats = a.includeStats === true;
|
|
3977
|
-
const servers = data
|
|
4903
|
+
const servers = data;
|
|
3978
4904
|
rememberServers(servers.map((s) => ({ id: s.id, name: s.name })));
|
|
3979
4905
|
let statsByServer = /* @__PURE__ */ new Map();
|
|
3980
4906
|
if (includeStats && servers.length > 0) {
|
|
@@ -4102,8 +5028,10 @@ ${contextHints.join("\n")}` : out }] };
|
|
|
4102
5028
|
const last = iterations[iterations.length - 1].result;
|
|
4103
5029
|
let serverName2 = serverId;
|
|
4104
5030
|
try {
|
|
4105
|
-
const
|
|
4106
|
-
|
|
5031
|
+
const nameRows = await db.execute(
|
|
5032
|
+
sql`SELECT name FROM ssh_server WHERE id = ${serverId} LIMIT 1`
|
|
5033
|
+
);
|
|
5034
|
+
if (nameRows[0]?.name) serverName2 = nameRows[0].name;
|
|
4107
5035
|
} catch {
|
|
4108
5036
|
}
|
|
4109
5037
|
return { serverId, serverName: serverName2, os, shell, result: last, iterations };
|
|
@@ -4118,8 +5046,10 @@ ${contextHints.join("\n")}` : out }] };
|
|
|
4118
5046
|
}
|
|
4119
5047
|
let serverName = serverId;
|
|
4120
5048
|
try {
|
|
4121
|
-
const
|
|
4122
|
-
|
|
5049
|
+
const nameRows = await db.execute(
|
|
5050
|
+
sql`SELECT name FROM ssh_server WHERE id = ${serverId} LIMIT 1`
|
|
5051
|
+
);
|
|
5052
|
+
if (nameRows[0]?.name) serverName = nameRows[0].name;
|
|
4123
5053
|
} catch {
|
|
4124
5054
|
}
|
|
4125
5055
|
return { serverId, serverName, os, shell, result, pipelineSegments };
|
|
@@ -4851,19 +5781,66 @@ ${trail.join("\n")}` }] };
|
|
|
4851
5781
|
// ----- Database -----
|
|
4852
5782
|
case "db-discover": {
|
|
4853
5783
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
4854
|
-
const
|
|
4855
|
-
|
|
4856
|
-
|
|
5784
|
+
const rawInclude = Array.isArray(a.include) ? a.include.map(String) : ["www"];
|
|
5785
|
+
const include = new Set(rawInclude.filter((m) => m === "www" || m === "postgres-containers"));
|
|
5786
|
+
if (include.size === 0) include.add("www");
|
|
5787
|
+
const sections = [];
|
|
5788
|
+
if (include.has("www")) {
|
|
5789
|
+
const sites = await discoverSiteDatabases(conn, proxy);
|
|
5790
|
+
if (!sites.length) {
|
|
5791
|
+
sections.push("## /var/www\n(none)");
|
|
5792
|
+
} else {
|
|
5793
|
+
const lines = sites.map(
|
|
5794
|
+
(s) => `${s.sitePath} [${s.appType}] db=${s.database} user=${s.user} host=${s.host}:${s.port}`
|
|
5795
|
+
);
|
|
5796
|
+
sections.push(`## /var/www (${sites.length})
|
|
5797
|
+
${lines.join("\n")}`);
|
|
5798
|
+
}
|
|
4857
5799
|
}
|
|
4858
|
-
|
|
4859
|
-
(
|
|
4860
|
-
|
|
4861
|
-
|
|
5800
|
+
if (include.has("postgres-containers")) {
|
|
5801
|
+
const containers = await discoverPostgresContainers(conn, proxy);
|
|
5802
|
+
if (!containers.length) {
|
|
5803
|
+
sections.push("## Postgres containers\n(none \u2014 no running container exposes POSTGRES_USER/POSTGRES_DB)");
|
|
5804
|
+
} else {
|
|
5805
|
+
const lines = containers.map(
|
|
5806
|
+
(c) => `${c.container} db=${c.db} user=${c.user} hostPort=${c.hostPort ?? "(internal-only)"} password=${c.hasPassword ? "set" : "unset"}`
|
|
5807
|
+
);
|
|
5808
|
+
sections.push(`## Postgres containers (${containers.length})
|
|
5809
|
+
${lines.join("\n")}
|
|
5810
|
+
|
|
5811
|
+
Tip: use these directly with \`db-query containerName=\u2026 dbName=\u2026 dbUser=\u2026\` or \`db-apply-migration\`.`);
|
|
5812
|
+
}
|
|
5813
|
+
}
|
|
5814
|
+
return { content: [{ type: "text", text: sections.join("\n\n") }] };
|
|
4862
5815
|
}
|
|
4863
5816
|
case "db-tables": {
|
|
4864
5817
|
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
4865
|
-
const
|
|
4866
|
-
|
|
5818
|
+
const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName : "";
|
|
5819
|
+
if (containerName) {
|
|
5820
|
+
const dbUser = typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "postgres";
|
|
5821
|
+
const dbName = typeof a.dbName === "string" && a.dbName ? a.dbName : "postgres";
|
|
5822
|
+
const sqlText2 = `SELECT n.nspname AS schema,
|
|
5823
|
+
c.relname AS "table",
|
|
5824
|
+
pg_size_pretty(pg_total_relation_size(c.oid)) AS size,
|
|
5825
|
+
c.reltuples::bigint AS estimated_rows
|
|
5826
|
+
FROM pg_class c
|
|
5827
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
5828
|
+
WHERE c.relkind = 'r'
|
|
5829
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
5830
|
+
ORDER BY pg_total_relation_size(c.oid) DESC;
|
|
5831
|
+
`;
|
|
5832
|
+
const res = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, sqlText2, "");
|
|
5833
|
+
const output2 = res.stdout.trim() || res.stderr.trim() || "(no output)";
|
|
5834
|
+
if (res.exitCode !== 0 && !res.stdout) {
|
|
5835
|
+
return { content: [{ type: "text", text: `Error (exit ${res.exitCode}): ${output2}` }] };
|
|
5836
|
+
}
|
|
5837
|
+
return { content: [{ type: "text", text: output2 || "No tables found" }] };
|
|
5838
|
+
}
|
|
5839
|
+
if (!a.sitePath) {
|
|
5840
|
+
return { content: [{ type: "text", text: "Error: pass `sitePath` (MySQL autodiscover) or `containerName` (Postgres container)." }] };
|
|
5841
|
+
}
|
|
5842
|
+
const sqlText = "SELECT TABLE_NAME, ENGINE, TABLE_ROWS, ROUND(DATA_LENGTH/1024/1024, 2) AS `Size (MB)`, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME";
|
|
5843
|
+
const output = await execSiteMysql(conn, String(a.sitePath), sqlText, proxy);
|
|
4867
5844
|
return { content: [{ type: "text", text: output || "No tables found" }] };
|
|
4868
5845
|
}
|
|
4869
5846
|
case "db-query": {
|
|
@@ -4881,7 +5858,24 @@ ${trail.join("\n")}` }] };
|
|
|
4881
5858
|
rawQuery = String(a.query ?? "").trim();
|
|
4882
5859
|
if (!rawQuery) return { content: [{ type: "text", text: "Error: query (or describe) is required" }] };
|
|
4883
5860
|
}
|
|
4884
|
-
|
|
5861
|
+
const allowDestructivePhrase = "yes-i-understand-this-is-not-logged";
|
|
5862
|
+
const allowDestructive = a.allowDestructive === allowDestructivePhrase;
|
|
5863
|
+
if (!allowDestructive && !describeArg) {
|
|
5864
|
+
try {
|
|
5865
|
+
assertSafeSql(rawQuery);
|
|
5866
|
+
} catch (err) {
|
|
5867
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5868
|
+
return {
|
|
5869
|
+
content: [{
|
|
5870
|
+
type: "text",
|
|
5871
|
+
text: `${msg}
|
|
5872
|
+
|
|
5873
|
+
For schema migrations use \`db-apply-migration\` \u2014 it allows DDL and records every change in the \`_mcp_migrations\` ledger for audit and idempotency.
|
|
5874
|
+
For a one-off ad-hoc destructive query, pass \`allowDestructive: "${allowDestructivePhrase}"\`.`
|
|
5875
|
+
}]
|
|
5876
|
+
};
|
|
5877
|
+
}
|
|
5878
|
+
}
|
|
4885
5879
|
let query = rawQuery.replace(/;\s*$/, "");
|
|
4886
5880
|
let appliedLimit = false;
|
|
4887
5881
|
if (explainMode && !describeArg) {
|
|
@@ -4900,12 +5894,13 @@ LIMIT ${maxRows + 1}`;
|
|
|
4900
5894
|
appliedLimit = true;
|
|
4901
5895
|
}
|
|
4902
5896
|
}
|
|
5897
|
+
const destructiveBanner = allowDestructive ? "\n\n[allowDestructive] safety gate bypassed for this query \u2014 NOT recorded in any audit ledger. Consider db-apply-migration for repeatable schema changes." : "";
|
|
4903
5898
|
if (!containerName && engine === "mysql") {
|
|
4904
5899
|
if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
|
|
4905
5900
|
const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
|
|
4906
5901
|
const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
|
|
4907
5902
|
const footer2 = formatDbQueryFooter(output2, appliedLimit, maxRows, explainMode);
|
|
4908
|
-
return { content: [{ type: "text", text: (output2 || "Query executed successfully (no output)") + footer2 }] };
|
|
5903
|
+
return { content: [{ type: "text", text: (output2 || "Query executed successfully (no output)") + footer2 + destructiveBanner }] };
|
|
4909
5904
|
}
|
|
4910
5905
|
if (!containerName) {
|
|
4911
5906
|
return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
|
|
@@ -4917,8 +5912,8 @@ LIMIT ${maxRows + 1}`;
|
|
|
4917
5912
|
let stdinPayload;
|
|
4918
5913
|
if (engine === "postgres") {
|
|
4919
5914
|
const user = dbUser || "postgres";
|
|
4920
|
-
const
|
|
4921
|
-
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`;
|
|
4922
5917
|
stdinPayload = query.endsWith(";") ? query : `${query};`;
|
|
4923
5918
|
} else if (engine === "mssql") {
|
|
4924
5919
|
const user = dbUser || "sa";
|
|
@@ -4933,10 +5928,10 @@ GO
|
|
|
4933
5928
|
`;
|
|
4934
5929
|
} else {
|
|
4935
5930
|
const user = dbUser || "root";
|
|
4936
|
-
const
|
|
5931
|
+
const db2 = dbName ? ` ${dbName}` : "";
|
|
4937
5932
|
const dbPass = typeof a.dbPass === "string" ? a.dbPass : "";
|
|
4938
5933
|
const passArg = dbPass ? `-p${posixQuote(dbPass)} ` : "";
|
|
4939
|
-
cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${
|
|
5934
|
+
cmd = `docker exec -i ${containerName} mysql -t -u ${user} ${passArg}${db2}`;
|
|
4940
5935
|
stdinPayload = query.endsWith(";") ? query : `${query};`;
|
|
4941
5936
|
}
|
|
4942
5937
|
const result = await sshExec(conn, cmd, proxy, { stdin: stdinPayload });
|
|
@@ -4945,38 +5940,233 @@ GO
|
|
|
4945
5940
|
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
|
|
4946
5941
|
}
|
|
4947
5942
|
const footer = formatDbQueryFooter(output, appliedLimit, maxRows, explainMode);
|
|
4948
|
-
return { content: [{ type: "text", text: output + footer }] };
|
|
5943
|
+
return { content: [{ type: "text", text: output + footer + destructiveBanner }] };
|
|
5944
|
+
}
|
|
5945
|
+
case "db-apply-migration": {
|
|
5946
|
+
const containerName = String(a.containerName || "").replace(/[^a-zA-Z0-9._-]/g, "");
|
|
5947
|
+
if (!containerName) return { content: [{ type: "text", text: "Error: containerName is required" }] };
|
|
5948
|
+
const dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
|
|
5949
|
+
const dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
|
|
5950
|
+
const name2 = String(a.name || "").trim();
|
|
5951
|
+
if (!name2) return { content: [{ type: "text", text: 'Error: name is required (e.g. "20260517120000_add_foo")' }] };
|
|
5952
|
+
if (!/^[\w.@:+\-]+$/.test(name2) || name2.length > 200) {
|
|
5953
|
+
return { content: [{ type: "text", text: "Error: name must be \u2264200 chars and contain only [A-Za-z0-9_.@:+-]" }] };
|
|
5954
|
+
}
|
|
5955
|
+
const noTransaction = a.noTransaction === true;
|
|
5956
|
+
const force = a.force === true;
|
|
5957
|
+
const sources = [
|
|
5958
|
+
typeof a.sql === "string" && a.sql ? "sql" : null,
|
|
5959
|
+
typeof a.localFile === "string" && a.localFile ? "localFile" : null,
|
|
5960
|
+
typeof a.remoteFile === "string" && a.remoteFile ? "remoteFile" : null
|
|
5961
|
+
].filter((v) => v !== null);
|
|
5962
|
+
if (sources.length !== 1) {
|
|
5963
|
+
return { content: [{ type: "text", text: `Error: pass exactly one of \`sql\`, \`localFile\`, or \`remoteFile\` (got ${sources.length || "none"}: ${sources.join(", ") || "\u2014"})` }] };
|
|
5964
|
+
}
|
|
5965
|
+
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
5966
|
+
let migrationSql;
|
|
5967
|
+
if (typeof a.sql === "string" && a.sql) {
|
|
5968
|
+
migrationSql = a.sql;
|
|
5969
|
+
} else if (typeof a.localFile === "string" && a.localFile) {
|
|
5970
|
+
const p = a.localFile;
|
|
5971
|
+
if (!isAbsolute(p) && !p.startsWith("./") && !p.startsWith("../")) {
|
|
5972
|
+
return { content: [{ type: "text", text: "Error: localFile must be an absolute path or start with ./ or ../" }] };
|
|
5973
|
+
}
|
|
5974
|
+
try {
|
|
5975
|
+
migrationSql = await readFile(p, "utf8");
|
|
5976
|
+
} catch (err) {
|
|
5977
|
+
return { content: [{ type: "text", text: `Error reading localFile "${p}": ${err instanceof Error ? err.message : String(err)}` }] };
|
|
5978
|
+
}
|
|
5979
|
+
} else {
|
|
5980
|
+
const remotePath = String(a.remoteFile);
|
|
5981
|
+
if (!remotePath.startsWith("/")) {
|
|
5982
|
+
return { content: [{ type: "text", text: "Error: remoteFile must be an absolute path on the SSH server" }] };
|
|
5983
|
+
}
|
|
5984
|
+
const catRes = await sshExec(conn, `cat ${posixQuote(remotePath)}`, proxy);
|
|
5985
|
+
if (catRes.exitCode !== 0) {
|
|
5986
|
+
return { content: [{ type: "text", text: `Error reading remoteFile "${remotePath}" on server: ${catRes.stderr || `exit ${catRes.exitCode}`}` }] };
|
|
5987
|
+
}
|
|
5988
|
+
migrationSql = catRes.stdout;
|
|
5989
|
+
}
|
|
5990
|
+
const normalised = normaliseMigrationSql(migrationSql);
|
|
5991
|
+
if (!normalised.trim()) {
|
|
5992
|
+
return { content: [{ type: "text", text: "Error: resolved SQL is empty" }] };
|
|
5993
|
+
}
|
|
5994
|
+
const sha = migrationSha256(migrationSql);
|
|
5995
|
+
const probeSql = `${MIGRATION_LEDGER_DDL}
|
|
5996
|
+
SELECT sha256 FROM _mcp_migrations WHERE name = ${dollarQuote(name2)};
|
|
5997
|
+
`;
|
|
5998
|
+
const probe = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, probeSql, "-tA");
|
|
5999
|
+
if (probe.exitCode !== 0) {
|
|
6000
|
+
return { content: [{ type: "text", text: `Error initialising ledger on ${containerName} (${dbName}): ${probe.stderr || probe.stdout || `exit ${probe.exitCode}`}` }] };
|
|
6001
|
+
}
|
|
6002
|
+
const probeLines = probe.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
6003
|
+
const existingSha = probeLines.length > 0 ? probeLines[probeLines.length - 1] : "";
|
|
6004
|
+
if (existingSha && existingSha === sha) {
|
|
6005
|
+
return {
|
|
6006
|
+
content: [{
|
|
6007
|
+
type: "text",
|
|
6008
|
+
text: `[noop] Migration "${name2}" already applied on ${containerName}/${dbName} (sha256 matches).
|
|
6009
|
+
Ledger sha: ${sha.slice(0, 12)}\u2026`
|
|
6010
|
+
}]
|
|
6011
|
+
};
|
|
6012
|
+
}
|
|
6013
|
+
if (existingSha && existingSha !== sha && !force) {
|
|
6014
|
+
return {
|
|
6015
|
+
content: [{
|
|
6016
|
+
type: "text",
|
|
6017
|
+
text: `[drift] Migration "${name2}" exists in ledger with DIFFERENT sha256.
|
|
6018
|
+
ledger sha: ${existingSha.slice(0, 12)}\u2026
|
|
6019
|
+
new sha: ${sha.slice(0, 12)}\u2026
|
|
6020
|
+
|
|
6021
|
+
This usually means the migration file was edited after it was first applied. Inspect the differences and either revert the source file or call again with \`force: true\` to overwrite the ledger entry (the SQL will be re-executed).`
|
|
6022
|
+
}]
|
|
6023
|
+
};
|
|
6024
|
+
}
|
|
6025
|
+
const appliedBy = `${authContext.apiKeyName} (${authContext.roleName})`;
|
|
6026
|
+
const ledgerInsert = `INSERT INTO _mcp_migrations (name, sha256, applied_by) VALUES (${dollarQuote(name2)}, ${dollarQuote(sha)}, ${dollarQuote(appliedBy)})
|
|
6027
|
+
ON CONFLICT (name) DO UPDATE SET sha256 = EXCLUDED.sha256, applied_at = now(), applied_by = EXCLUDED.applied_by;
|
|
6028
|
+
`;
|
|
6029
|
+
if (noTransaction) {
|
|
6030
|
+
const apply = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, normalised, "");
|
|
6031
|
+
if (apply.exitCode !== 0) {
|
|
6032
|
+
return {
|
|
6033
|
+
content: [{
|
|
6034
|
+
type: "text",
|
|
6035
|
+
text: `[failed] Migration "${name2}" did not apply cleanly (noTransaction=true, no ledger entry recorded).
|
|
6036
|
+
psql exit: ${apply.exitCode}
|
|
6037
|
+
` + (apply.stderr ? `--- stderr ---
|
|
6038
|
+
${apply.stderr}
|
|
6039
|
+
` : "") + (apply.stdout ? `--- stdout ---
|
|
6040
|
+
${apply.stdout}` : "")
|
|
6041
|
+
}]
|
|
6042
|
+
};
|
|
6043
|
+
}
|
|
6044
|
+
const record = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, ledgerInsert, "");
|
|
6045
|
+
if (record.exitCode !== 0) {
|
|
6046
|
+
return {
|
|
6047
|
+
content: [{
|
|
6048
|
+
type: "text",
|
|
6049
|
+
text: `[partial] Migration "${name2}" applied but ledger insert FAILED. Re-run with \`force: true\` after the issue is resolved.
|
|
6050
|
+
psql exit: ${record.exitCode}
|
|
6051
|
+
` + (record.stderr ? `--- stderr ---
|
|
6052
|
+
${record.stderr}
|
|
6053
|
+
` : "") + (record.stdout ? `--- stdout ---
|
|
6054
|
+
${record.stdout}` : "")
|
|
6055
|
+
}]
|
|
6056
|
+
};
|
|
6057
|
+
}
|
|
6058
|
+
return {
|
|
6059
|
+
content: [{
|
|
6060
|
+
type: "text",
|
|
6061
|
+
text: `[applied] "${name2}" on ${containerName}/${dbName} (noTransaction=true${existingSha ? ", force" : ""}).
|
|
6062
|
+
ledger sha: ${sha.slice(0, 12)}\u2026
|
|
6063
|
+
` + (apply.stdout ? `--- output ---
|
|
6064
|
+
${apply.stdout.trim()}` : "(no output)")
|
|
6065
|
+
}]
|
|
6066
|
+
};
|
|
6067
|
+
}
|
|
6068
|
+
const wrappedSql = `BEGIN;
|
|
6069
|
+
${normalised}
|
|
6070
|
+
${ledgerInsert}COMMIT;
|
|
6071
|
+
`;
|
|
6072
|
+
const res = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, wrappedSql, "");
|
|
6073
|
+
if (res.exitCode !== 0) {
|
|
6074
|
+
return {
|
|
6075
|
+
content: [{
|
|
6076
|
+
type: "text",
|
|
6077
|
+
text: `[failed] Migration "${name2}" did not apply (transaction rolled back, ledger unchanged).
|
|
6078
|
+
psql exit: ${res.exitCode}
|
|
6079
|
+
` + (res.stderr ? `--- stderr ---
|
|
6080
|
+
${res.stderr}
|
|
6081
|
+
` : "") + (res.stdout ? `--- stdout ---
|
|
6082
|
+
${res.stdout}` : "")
|
|
6083
|
+
}]
|
|
6084
|
+
};
|
|
6085
|
+
}
|
|
6086
|
+
return {
|
|
6087
|
+
content: [{
|
|
6088
|
+
type: "text",
|
|
6089
|
+
text: `[applied] "${name2}" on ${containerName}/${dbName}${existingSha ? " (forced overwrite)" : ""}.
|
|
6090
|
+
ledger sha: ${sha.slice(0, 12)}\u2026
|
|
6091
|
+
` + (res.stdout ? `--- output ---
|
|
6092
|
+
${res.stdout.trim()}` : "(no output)")
|
|
6093
|
+
}]
|
|
6094
|
+
};
|
|
6095
|
+
}
|
|
6096
|
+
case "db-list-migrations": {
|
|
6097
|
+
const containerName = String(a.containerName || "").replace(/[^a-zA-Z0-9._-]/g, "");
|
|
6098
|
+
if (!containerName) return { content: [{ type: "text", text: "Error: containerName is required" }] };
|
|
6099
|
+
const dbUser = (typeof a.dbUser === "string" && a.dbUser ? a.dbUser : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
|
|
6100
|
+
const dbName = (typeof a.dbName === "string" && a.dbName ? a.dbName : "postgres").replace(/[^a-zA-Z0-9_-]/g, "") || "postgres";
|
|
6101
|
+
const limitRaw = Number(a.limit);
|
|
6102
|
+
const limit = Math.min(Math.max(Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 50, 1), 500);
|
|
6103
|
+
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
6104
|
+
const sqlText = `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = '_mcp_migrations') THEN RAISE NOTICE 'NO_LEDGER'; END IF; END $$;
|
|
6105
|
+
SELECT name, substr(sha256, 1, 12) || '\u2026' AS sha, applied_at, applied_by
|
|
6106
|
+
FROM _mcp_migrations
|
|
6107
|
+
ORDER BY applied_at DESC
|
|
6108
|
+
LIMIT ${limit};
|
|
6109
|
+
`;
|
|
6110
|
+
const res = await psqlInContainer(conn, proxy, containerName, dbUser, dbName, sqlText, "");
|
|
6111
|
+
if (res.exitCode !== 0) {
|
|
6112
|
+
if (/relation "_mcp_migrations" does not exist/.test(res.stderr) || /relation "_mcp_migrations" does not exist/.test(res.stdout)) {
|
|
6113
|
+
return { content: [{ type: "text", text: `No \`_mcp_migrations\` ledger on ${containerName}/${dbName} yet. Apply your first migration with \`db-apply-migration\` to create it.` }] };
|
|
6114
|
+
}
|
|
6115
|
+
return { content: [{ type: "text", text: `Error (exit ${res.exitCode}): ${res.stderr || res.stdout}` }] };
|
|
6116
|
+
}
|
|
6117
|
+
const output = res.stdout.trim();
|
|
6118
|
+
return { content: [{ type: "text", text: output || `Ledger exists on ${containerName}/${dbName} but is empty.` }] };
|
|
4949
6119
|
}
|
|
4950
6120
|
// ----- Env Config -----
|
|
4951
6121
|
case "env-list": {
|
|
4952
|
-
let
|
|
6122
|
+
let stageFilterIds = null;
|
|
4953
6123
|
if (a.releaseProfile) {
|
|
4954
6124
|
const { stageIds: stageIds2 } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
|
|
4955
|
-
|
|
6125
|
+
stageFilterIds = stageIds2;
|
|
4956
6126
|
}
|
|
4957
|
-
const
|
|
4958
|
-
|
|
4959
|
-
|
|
6127
|
+
const data = stageFilterIds ? await db.execute(sql`
|
|
6128
|
+
SELECT id, app_name, environment, description, updated_at, release_profile_stage_id
|
|
6129
|
+
FROM env_config
|
|
6130
|
+
WHERE release_profile_stage_id = ANY(${stageFilterIds}::uuid[])
|
|
6131
|
+
ORDER BY app_name, environment
|
|
6132
|
+
`) : await db.execute(sql`
|
|
6133
|
+
SELECT id, app_name, environment, description, updated_at, release_profile_stage_id
|
|
6134
|
+
FROM env_config
|
|
6135
|
+
ORDER BY app_name, environment
|
|
6136
|
+
`);
|
|
6137
|
+
const stageIds = data.map((e) => e.release_profile_stage_id).filter((v) => Boolean(v));
|
|
4960
6138
|
const profileNames = await getProfileNamesForStageIds(stageIds);
|
|
4961
|
-
const lines =
|
|
6139
|
+
const lines = data.map((e) => {
|
|
4962
6140
|
const profile = e.release_profile_stage_id ? profileNames[e.release_profile_stage_id] || "unknown" : "unlinked";
|
|
4963
6141
|
return `${e.app_name}/${e.environment} [${profile}] (updated: ${e.updated_at})`;
|
|
4964
6142
|
});
|
|
4965
6143
|
return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No environment configs stored" }] };
|
|
4966
6144
|
}
|
|
4967
6145
|
case "env-get": {
|
|
4968
|
-
|
|
6146
|
+
const appName = String(a.appName);
|
|
6147
|
+
const environment = String(a.environment);
|
|
6148
|
+
let stageFilterIds = null;
|
|
4969
6149
|
if (a.releaseProfile) {
|
|
4970
6150
|
const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
|
|
4971
|
-
|
|
6151
|
+
stageFilterIds = stageIds;
|
|
4972
6152
|
}
|
|
4973
|
-
const
|
|
4974
|
-
|
|
4975
|
-
|
|
6153
|
+
const data = stageFilterIds ? await db.execute(sql`
|
|
6154
|
+
SELECT env_data_encrypted, release_profile_stage_id
|
|
6155
|
+
FROM env_config
|
|
6156
|
+
WHERE app_name = ${appName}
|
|
6157
|
+
AND environment = ${environment}
|
|
6158
|
+
AND release_profile_stage_id = ANY(${stageFilterIds}::uuid[])
|
|
6159
|
+
`) : await db.execute(sql`
|
|
6160
|
+
SELECT env_data_encrypted, release_profile_stage_id
|
|
6161
|
+
FROM env_config
|
|
6162
|
+
WHERE app_name = ${appName}
|
|
6163
|
+
AND environment = ${environment}
|
|
6164
|
+
`);
|
|
6165
|
+
if (data.length === 0) {
|
|
4976
6166
|
throw new Error(`Env config not found: ${a.appName}/${a.environment}${a.releaseProfile ? ` (profile: ${a.releaseProfile})` : ""}`);
|
|
4977
6167
|
}
|
|
4978
6168
|
if (data.length > 1) {
|
|
4979
|
-
const stageIds = data.map((r) => r.release_profile_stage_id).filter(Boolean);
|
|
6169
|
+
const stageIds = data.map((r) => r.release_profile_stage_id).filter((v) => Boolean(v));
|
|
4980
6170
|
const profileNames = await getProfileNamesForStageIds(stageIds);
|
|
4981
6171
|
const names = [...new Set(Object.values(profileNames))].join(", ");
|
|
4982
6172
|
throw new Error(
|
|
@@ -4995,45 +6185,56 @@ GO
|
|
|
4995
6185
|
const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
|
|
4996
6186
|
resolvedStageIds = stageIds;
|
|
4997
6187
|
}
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
6188
|
+
const existingRows = resolvedStageIds ? await db.execute(sql`
|
|
6189
|
+
SELECT id, release_profile_stage_id
|
|
6190
|
+
FROM env_config
|
|
6191
|
+
WHERE app_name = ${appName}
|
|
6192
|
+
AND environment = ${environment}
|
|
6193
|
+
AND release_profile_stage_id = ANY(${resolvedStageIds}::uuid[])
|
|
6194
|
+
`) : await db.execute(sql`
|
|
6195
|
+
SELECT id, release_profile_stage_id
|
|
6196
|
+
FROM env_config
|
|
6197
|
+
WHERE app_name = ${appName}
|
|
6198
|
+
AND environment = ${environment}
|
|
6199
|
+
`);
|
|
6200
|
+
if (existingRows.length > 1 && !resolvedStageIds) {
|
|
6201
|
+
const stageIds = existingRows.map((r) => r.release_profile_stage_id).filter((v) => Boolean(v));
|
|
5006
6202
|
const profileNames = await getProfileNamesForStageIds(stageIds);
|
|
5007
6203
|
const names = [...new Set(Object.values(profileNames))].join(", ");
|
|
5008
6204
|
throw new Error(
|
|
5009
6205
|
`Multiple env configs found for ${appName}/${environment} across profiles: ${names}. Pass releaseProfile parameter to select one.`
|
|
5010
6206
|
);
|
|
5011
6207
|
}
|
|
5012
|
-
const existing = existingRows
|
|
6208
|
+
const existing = existingRows[0] ?? null;
|
|
6209
|
+
const description = a.description ? String(a.description) : null;
|
|
6210
|
+
const updatedBy = authContext.userId;
|
|
5013
6211
|
let saveMsg;
|
|
5014
6212
|
if (existing) {
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
6213
|
+
await db.execute(sql`
|
|
6214
|
+
UPDATE env_config
|
|
6215
|
+
SET env_data_encrypted = ${encrypted},
|
|
6216
|
+
description = COALESCE(${description}, description),
|
|
6217
|
+
updated_by = ${updatedBy},
|
|
6218
|
+
updated_at = ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
6219
|
+
WHERE id = ${existing.id}
|
|
6220
|
+
`);
|
|
5022
6221
|
saveMsg = `Updated env config: ${appName}/${environment}`;
|
|
5023
6222
|
} else {
|
|
5024
|
-
const
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
6223
|
+
const stageId = resolvedStageIds?.[0] ?? null;
|
|
6224
|
+
await db.execute(sql`
|
|
6225
|
+
INSERT INTO env_config (
|
|
6226
|
+
app_name, environment, env_data_encrypted, description,
|
|
6227
|
+
created_by, updated_by, release_profile_stage_id
|
|
6228
|
+
) VALUES (
|
|
6229
|
+
${appName},
|
|
6230
|
+
${environment},
|
|
6231
|
+
${encrypted},
|
|
6232
|
+
${description},
|
|
6233
|
+
${updatedBy},
|
|
6234
|
+
${updatedBy},
|
|
6235
|
+
${stageId}
|
|
6236
|
+
)
|
|
6237
|
+
`);
|
|
5037
6238
|
saveMsg = `Stored env config: ${appName}/${environment}`;
|
|
5038
6239
|
}
|
|
5039
6240
|
const syncStageId = existing?.release_profile_stage_id ?? resolvedStageIds?.[0];
|
|
@@ -5300,7 +6501,10 @@ ${lines.join("\n")}` }] };
|
|
|
5300
6501
|
return handleTriggerTool(name, a, { sshExec, getServerConnection });
|
|
5301
6502
|
}
|
|
5302
6503
|
if (VERCEL_TOOL_NAMES.has(name)) {
|
|
5303
|
-
return handleVercelTool(name, a, {
|
|
6504
|
+
return handleVercelTool(name, a, { db, decrypt });
|
|
6505
|
+
}
|
|
6506
|
+
if (REPO_TOOL_NAMES.has(name)) {
|
|
6507
|
+
return handleRepoTool(name, a, { db, sshExec, getServerConnection });
|
|
5304
6508
|
}
|
|
5305
6509
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
|
5306
6510
|
}
|