@mgsoftwarebv/mg-dashboard-mcp 6.4.0 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { createServer as createServer$1 } from 'net';
11
11
  import { Client } from 'ssh2';
12
12
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
13
13
  import { createServer } from 'http';
14
- import { randomUUID, createHash, randomBytes, createDecipheriv, createCipheriv } from 'crypto';
14
+ import { randomUUID, createHash, randomBytes, createCipheriv, createDecipheriv } from 'crypto';
15
15
  import { sql } from 'drizzle-orm';
16
16
  import { drizzle } from 'drizzle-orm/postgres-js';
17
17
  import postgres from 'postgres';
@@ -460,7 +460,7 @@ var connectionConfig = {
460
460
  }
461
461
  },
462
462
  connection: {
463
- application_name: `mg-dashboard-${process.env.VERCEL ? "vercel" : process.env.TRIGGER_RUN_ID ? "trigger" : "local"}`
463
+ application_name: `mg-dashboard-${process.env.TRIGGER_RUN_ID ? "trigger" : "local"}`
464
464
  }
465
465
  };
466
466
  function getDatabaseUrl() {
@@ -556,10 +556,10 @@ var TRIGGER_TOOL_MODULE_MAP = {
556
556
  "trigger-run": "ci_cd"
557
557
  };
558
558
  async function discoverInstance(projectSlug, conn, proxy, sshExec2) {
559
- 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`;
559
+ const sql3 = `SELECT re.\\"apiKey\\" FROM \\"RuntimeEnvironment\\" re JOIN \\"Project\\" p ON re.\\"projectId\\" = p.id WHERE p.slug='${projectSlug}' AND re.slug='prod' LIMIT 1`;
560
560
  const cmd = [
561
561
  `PORT=$(docker port "${WA_CONTAINER}" 3000/tcp 2>/dev/null | head -1 | sed 's/.*://')`,
562
- `KEY=$(docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql4}" 2>/dev/null | tr -d '[:space:]')`,
562
+ `KEY=$(docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql3}" 2>/dev/null | tr -d '[:space:]')`,
563
563
  'echo "$PORT|$KEY"'
564
564
  ].join(" && ");
565
565
  const result = await sshExec2(conn, cmd, proxy);
@@ -580,8 +580,8 @@ async function discoverInstance(projectSlug, conn, proxy, sshExec2) {
580
580
  return { port, apiKey: apiKey2 };
581
581
  }
582
582
  async function fetchRunLogs(runId, conn, proxy, sshExec2) {
583
- 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`;
584
- const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql4}" 2>/dev/null`;
583
+ const sql3 = `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`;
584
+ const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql3}" 2>/dev/null`;
585
585
  const result = await sshExec2(conn, cmd, proxy);
586
586
  const output = result.stdout.trim();
587
587
  if (!output) return "";
@@ -663,8 +663,8 @@ async function handleTriggerTool(name, args2, deps) {
663
663
  switch (name) {
664
664
  // -----------------------------------------------------------------
665
665
  case "trigger-list": {
666
- const sql4 = 'SELECT slug, name FROM \\"Project\\" ORDER BY name';
667
- const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql4}" 2>/dev/null`;
666
+ const sql3 = 'SELECT slug, name FROM \\"Project\\" ORDER BY name';
667
+ const cmd = `docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql3}" 2>/dev/null`;
668
668
  const result = await sshExec2(conn, cmd, proxy);
669
669
  const output = result.stdout.trim();
670
670
  if (!output) {
@@ -859,621 +859,6 @@ async function waitForCompletion(conn, proxy, sshExec2, instance, runId, waitSec
859
859
  }]
860
860
  };
861
861
  }
862
- var VERCEL_API = "https://api.vercel.com";
863
- var VERCEL_TOOLS = [
864
- {
865
- name: "vercel-projects",
866
- description: "List Vercel projects in the configured account/team. Returns id, name, framework, GitHub repo link, production URL, and the latest deployment summary. Use the project name or id as input to vercel-deployments.",
867
- inputSchema: {
868
- type: "object",
869
- properties: {
870
- limit: { type: "number", description: "Max projects to return (default 50, max 200)" },
871
- search: { type: "string", description: "Substring filter on project name (case-insensitive)" }
872
- }
873
- }
874
- },
875
- {
876
- name: "vercel-deployments",
877
- description: "List recent deployments for a Vercel project. Returns deployment ID, state, target, branch, commit and timestamps. Use the deployment ID with vercel-logs.",
878
- inputSchema: {
879
- type: "object",
880
- properties: {
881
- project: { type: "string", description: "Vercel project ID or name (from vercel-projects)" },
882
- state: {
883
- type: "string",
884
- description: "Optional state filter: BUILDING, ERROR, INITIALIZING, QUEUED, READY, CANCELED"
885
- },
886
- target: { type: "string", description: "Optional target filter: production or preview" },
887
- limit: { type: "number", description: "Max deployments to return (default 20, max 100)" }
888
- },
889
- required: ["project"]
890
- }
891
- },
892
- {
893
- name: "vercel-domains",
894
- description: 'Manage domains attached to a Vercel project. Use `action` to pick the operation:\n- "list" (default): list all domains attached to the project, including verification status and any redirect.\n- "add": attach a new domain to the project. Returns DNS records to set if the domain is not yet verified.\n- "verify": re-check verification status and surface required CNAME / A / TXT records if misconfigured.\n- "remove": detach a domain from the project.\nPick the project via vercel-projects first.',
895
- inputSchema: {
896
- type: "object",
897
- properties: {
898
- action: {
899
- type: "string",
900
- enum: ["list", "add", "remove", "verify"],
901
- description: "Which operation to perform (default: list)."
902
- },
903
- project: {
904
- type: "string",
905
- description: "Vercel project ID or name (from vercel-projects)."
906
- },
907
- domain: {
908
- type: "string",
909
- description: 'Domain name (required for action="add", "remove", or "verify").'
910
- },
911
- gitBranch: {
912
- type: "string",
913
- description: 'Optional git branch this domain should deploy from (action="add" only).'
914
- },
915
- redirect: {
916
- type: "string",
917
- description: 'Optional redirect target domain (action="add" only).'
918
- },
919
- redirectStatusCode: {
920
- type: "number",
921
- description: 'Optional redirect status code, e.g. 301 / 302 / 307 / 308 (action="add" only).'
922
- }
923
- },
924
- required: ["project"]
925
- }
926
- },
927
- {
928
- name: "vercel-logs",
929
- description: 'Unified log inspector for Vercel. Use `kind` to pick the source:\n- "build" (default): build / deployment console events (stdout, stderr, command, exit). Requires deploymentId.\n- "runtime": runtime / function logs after a successful build. Requires project + deploymentId.\n- "webhooks": our own vercel_webhook_logs table (Telegram / push delivery history). No deployment needed.\nPick deployments via vercel-deployments first.',
930
- inputSchema: {
931
- type: "object",
932
- properties: {
933
- kind: {
934
- type: "string",
935
- enum: ["build", "runtime", "webhooks"],
936
- description: "Which log stream to fetch (default: build)."
937
- },
938
- project: {
939
- type: "string",
940
- description: 'Vercel project ID or name (required for kind="runtime").'
941
- },
942
- deploymentId: {
943
- type: "string",
944
- description: 'Vercel deployment ID (required for kind="build" or "runtime").'
945
- },
946
- projectName: {
947
- type: "string",
948
- description: 'Optional project_name filter (kind="webhooks" only).'
949
- },
950
- status: {
951
- type: "string",
952
- description: 'Optional status filter (kind="webhooks" only): sent, skipped, error.'
953
- },
954
- sinceMinutes: {
955
- type: "number",
956
- description: `Time window in minutes (kind="runtime" only, max 7 days). When omitted, the tool auto-detects the deployment's created timestamp and queries from there with a 5-minute buffer \u2014 so you don't miss logs by picking a too-small window.`
957
- },
958
- limit: {
959
- type: "number",
960
- description: "Max entries to return. Defaults per kind: build=500 (max 5000), runtime=200 (max 1000), webhooks=25 (max 200)."
961
- }
962
- }
963
- }
964
- }
965
- ];
966
- var VERCEL_TOOL_NAMES = new Set(VERCEL_TOOLS.map((t) => t.name));
967
- var VERCEL_TOOL_MODULE_MAP = {
968
- "vercel-projects": "ci_cd",
969
- "vercel-deployments": "ci_cd",
970
- "vercel-logs": "ci_cd",
971
- "vercel-domains": "ci_cd"
972
- };
973
- async function vercelFetch(token, path, init) {
974
- const res = await fetch(`${VERCEL_API}${path}`, {
975
- method: init?.method ?? "GET",
976
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
977
- body: init?.body !== void 0 ? JSON.stringify(init.body) : void 0
978
- });
979
- const body = await res.text();
980
- let parsed = null;
981
- try {
982
- parsed = body ? JSON.parse(body) : null;
983
- } catch {
984
- }
985
- if (!res.ok) {
986
- const msg = parsed?.error?.message ?? parsed?.message ?? body ?? `HTTP ${res.status}`;
987
- return { data: null, error: `Vercel API ${res.status}: ${msg}`, status: res.status };
988
- }
989
- return { data: parsed, error: null, status: res.status };
990
- }
991
- async function listVercelProjectsAll(token, limit) {
992
- const collected = [];
993
- let nextTimestamp;
994
- while (collected.length < limit) {
995
- const params = new URLSearchParams({ limit: String(Math.min(limit - collected.length, 100)) });
996
- if (nextTimestamp) params.set("until", String(nextTimestamp));
997
- const res = await vercelFetch(token, `/v9/projects?${params.toString()}`);
998
- if (res.error) return { projects: [], error: res.error };
999
- if (!res.data?.projects?.length) break;
1000
- collected.push(...res.data.projects);
1001
- if (!res.data.pagination?.next) break;
1002
- nextTimestamp = res.data.pagination.next;
1003
- }
1004
- return { projects: collected, error: null };
1005
- }
1006
- async function listVercelDeployments(token, options) {
1007
- const params = new URLSearchParams({
1008
- limit: String(options.limit),
1009
- projectId: options.projectId
1010
- });
1011
- if (options.state) params.set("state", options.state);
1012
- if (options.target) params.set("target", options.target);
1013
- const res = await vercelFetch(
1014
- token,
1015
- `/v6/deployments?${params.toString()}`
1016
- );
1017
- if (res.error) return { deployments: [], error: res.error };
1018
- return { deployments: res.data?.deployments ?? [], error: null };
1019
- }
1020
- async function getDeploymentBuildEvents(token, deploymentId, limit) {
1021
- const params = new URLSearchParams({
1022
- limit: String(limit),
1023
- direction: "forward",
1024
- follow: "0"
1025
- });
1026
- const url = `${VERCEL_API}/v3/deployments/${encodeURIComponent(deploymentId)}/events?${params.toString()}`;
1027
- const res = await fetch(url, {
1028
- headers: { Authorization: `Bearer ${token}` }
1029
- });
1030
- const body = await res.text();
1031
- if (!res.ok) {
1032
- let msg = body;
1033
- try {
1034
- const parsed = JSON.parse(body);
1035
- msg = parsed?.error?.message ?? parsed?.message ?? body;
1036
- } catch {
1037
- }
1038
- return { events: [], error: `Vercel API ${res.status}: ${msg}` };
1039
- }
1040
- const events = [];
1041
- const trimmed = body.trim();
1042
- if (!trimmed) return { events, error: null };
1043
- try {
1044
- const parsed = JSON.parse(trimmed);
1045
- if (Array.isArray(parsed)) {
1046
- for (const r of parsed) events.push(r);
1047
- return { events, error: null };
1048
- }
1049
- } catch {
1050
- }
1051
- for (const line of trimmed.split("\n")) {
1052
- const s = line.trim();
1053
- if (!s) continue;
1054
- try {
1055
- events.push(JSON.parse(s));
1056
- } catch {
1057
- }
1058
- }
1059
- return { events, error: null };
1060
- }
1061
- async function getDeploymentCreatedMs(token, deploymentId) {
1062
- const res = await vercelFetch(
1063
- token,
1064
- `/v13/deployments/${encodeURIComponent(deploymentId)}`
1065
- );
1066
- if (res.error || !res.data) return null;
1067
- return res.data.createdAt ?? res.data.created ?? null;
1068
- }
1069
- async function getRuntimeLogs(token, projectIdOrName, deploymentId, limit, sinceMs) {
1070
- const params = new URLSearchParams({
1071
- limit: String(Math.min(Math.max(limit, 1), 1e3))
1072
- });
1073
- if (sinceMs) params.set("since", String(sinceMs));
1074
- const url = `${VERCEL_API}/v1/projects/${encodeURIComponent(projectIdOrName)}/deployments/${encodeURIComponent(deploymentId)}/runtime-logs?${params.toString()}`;
1075
- const res = await fetch(url, {
1076
- headers: { Authorization: `Bearer ${token}` }
1077
- });
1078
- const body = await res.text();
1079
- if (!res.ok) {
1080
- let msg = body;
1081
- try {
1082
- const parsed = JSON.parse(body);
1083
- msg = parsed?.error?.message ?? parsed?.message ?? body;
1084
- } catch {
1085
- }
1086
- return { logs: [], error: `Vercel API ${res.status}: ${msg}` };
1087
- }
1088
- const logs = [];
1089
- const trimmed = body.trim();
1090
- if (!trimmed) return { logs, error: null };
1091
- try {
1092
- const parsed = JSON.parse(trimmed);
1093
- if (Array.isArray(parsed)) {
1094
- for (const r of parsed) logs.push(r);
1095
- return { logs, error: null };
1096
- }
1097
- } catch {
1098
- }
1099
- for (const line of trimmed.split("\n")) {
1100
- const s = line.trim();
1101
- if (!s) continue;
1102
- try {
1103
- logs.push(JSON.parse(s));
1104
- } catch {
1105
- }
1106
- }
1107
- return { logs, error: null };
1108
- }
1109
- async function listProjectDomains(token, projectId) {
1110
- const res = await vercelFetch(
1111
- token,
1112
- `/v9/projects/${encodeURIComponent(projectId)}/domains?limit=100`
1113
- );
1114
- if (res.error) return { domains: [], error: res.error };
1115
- return { domains: res.data?.domains ?? [], error: null };
1116
- }
1117
- async function addProjectDomain(token, projectId, body) {
1118
- const res = await vercelFetch(
1119
- token,
1120
- `/v10/projects/${encodeURIComponent(projectId)}/domains`,
1121
- { method: "POST", body }
1122
- );
1123
- if (res.error) return { domain: null, error: res.error };
1124
- return { domain: res.data, error: null };
1125
- }
1126
- async function removeProjectDomain(token, projectId, domain) {
1127
- const res = await vercelFetch(
1128
- token,
1129
- `/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}`,
1130
- { method: "DELETE" }
1131
- );
1132
- return { error: res.error };
1133
- }
1134
- async function getProjectDomain(token, projectId, domain) {
1135
- const res = await vercelFetch(
1136
- token,
1137
- `/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}`
1138
- );
1139
- if (res.error) return { domain: null, error: res.error };
1140
- return { domain: res.data, error: null };
1141
- }
1142
- async function getDomainConfig(token, domain) {
1143
- const res = await vercelFetch(
1144
- token,
1145
- `/v6/domains/${encodeURIComponent(domain)}/config`
1146
- );
1147
- if (res.error) return { config: null, error: res.error };
1148
- return { config: res.data, error: null };
1149
- }
1150
- async function getVercelToken(deps) {
1151
- const rows = await deps.db.execute(sql`
1152
- SELECT vercel_token_encrypted FROM app_setting LIMIT 1
1153
- `);
1154
- const data = rows[0];
1155
- if (!data?.vercel_token_encrypted) {
1156
- throw new Error("Vercel API token is not configured. Add it in dashboard Settings.");
1157
- }
1158
- try {
1159
- return deps.decrypt(data.vercel_token_encrypted);
1160
- } catch {
1161
- throw new Error("Failed to decrypt the stored Vercel API token.");
1162
- }
1163
- }
1164
- async function resolveProjectId(token, projectInput) {
1165
- const res = await vercelFetch(
1166
- token,
1167
- `/v9/projects/${encodeURIComponent(projectInput)}`
1168
- );
1169
- if (res.error || !res.data?.id) {
1170
- throw new Error(
1171
- `Could not resolve Vercel project "${projectInput}": ${res.error ?? "not found"}`
1172
- );
1173
- }
1174
- return res.data.id;
1175
- }
1176
- function formatTimestamp(ms) {
1177
- if (!ms) return "";
1178
- return new Date(ms).toLocaleString("nl-NL", { timeZone: "Europe/Amsterdam" });
1179
- }
1180
- function formatProjectsTable(projects) {
1181
- if (projects.length === 0) return "No Vercel projects found";
1182
- const lines = projects.map((p) => {
1183
- const repo = p.link ? `${p.link.org ?? ""}/${p.link.repo ?? ""}` : "";
1184
- const prodUrl = p.targets?.production?.alias?.[0] ?? p.targets?.production?.url ?? "";
1185
- const latest = p.latestDeployments?.[0];
1186
- const latestStr = latest ? `${latest.state} @ ${formatTimestamp(latest.createdAt)}` : "";
1187
- return `${p.id.padEnd(28)} ${(p.name || "").padEnd(40)} ${(p.framework || "-").padEnd(10)} ${repo.padEnd(40)} ${prodUrl.padEnd(35)} ${latestStr}`;
1188
- });
1189
- const header = `${"ID".padEnd(28)} ${"NAME".padEnd(40)} ${"FRAMEWORK".padEnd(10)} ${"REPO".padEnd(40)} ${"PROD URL".padEnd(35)} LATEST`;
1190
- return `${header}
1191
- ${"-".repeat(header.length)}
1192
- ${lines.join("\n")}`;
1193
- }
1194
- function formatDeploymentsTable(deployments) {
1195
- if (deployments.length === 0) return "No deployments found";
1196
- const lines = deployments.map((d) => {
1197
- const branch = d.meta?.githubCommitRef ?? "";
1198
- const sha = d.meta?.githubCommitSha?.slice(0, 7) ?? "";
1199
- const msg = (d.meta?.githubCommitMessage ?? "").replace(/\n.*/s, "").slice(0, 60);
1200
- return `${d.uid.padEnd(28)} ${d.state.padEnd(11)} ${(d.target || "-").padEnd(10)} ${branch.padEnd(20)} ${sha.padEnd(8)} ${formatTimestamp(d.created).padEnd(20)} ${msg}`;
1201
- });
1202
- const header = `${"ID".padEnd(28)} ${"STATE".padEnd(11)} ${"TARGET".padEnd(10)} ${"BRANCH".padEnd(20)} ${"COMMIT".padEnd(8)} ${"CREATED".padEnd(20)} MESSAGE`;
1203
- return `${header}
1204
- ${"-".repeat(header.length)}
1205
- ${lines.join("\n")}`;
1206
- }
1207
- function formatBuildEvents(events) {
1208
- if (events.length === 0) return "No build events found for this deployment";
1209
- const filtered = events.filter(
1210
- (e) => ["command", "stdout", "stderr", "exit", "delimiter", "deployment-state"].includes(e.type)
1211
- );
1212
- return filtered.map((e) => {
1213
- const time = e.created ? new Date(e.created).toISOString().slice(11, 19) : "--:--:--";
1214
- const text = (e.payload?.text ?? e.text ?? "").replace(/\s+$/u, "");
1215
- const prefix = e.type === "stderr" ? "!" : " ";
1216
- return `${prefix}${time} [${e.type.padEnd(16)}] ${text}`;
1217
- }).join("\n");
1218
- }
1219
- function formatRuntimeLogs(logs) {
1220
- if (logs.length === 0) {
1221
- return "No runtime logs found for this deployment in the requested window";
1222
- }
1223
- return logs.map((l) => {
1224
- const ts = l.timestampInMs ?? l.timestamp ?? 0;
1225
- const time = ts ? new Date(ts).toISOString().slice(11, 23) : "--:--:--";
1226
- const level = (l.level ?? "info").toUpperCase().padEnd(5);
1227
- const status = l.proxy?.statusCode ?? l.responseStatusCode;
1228
- const reqInfo = l.proxy?.method ? `${l.proxy.method} ${l.proxy.path ?? ""} ${status ?? ""}`.trim() : "";
1229
- const message = (l.message ?? l.text ?? "").replace(/\s+$/u, "");
1230
- const reqId = l.requestId ? ` [${l.requestId.slice(0, 12)}]` : "";
1231
- const head = reqInfo ? `${reqInfo}${reqId}` : reqId.trim();
1232
- return `${time} [${level}] ${head ? head + " " : ""}${message}`;
1233
- }).join("\n");
1234
- }
1235
- function formatWebhookHistory(rows) {
1236
- if (rows.length === 0) return "No webhook log rows match those filters";
1237
- const lines = rows.map((r) => {
1238
- const ts = new Date(r.created_at).toLocaleString("nl-NL", { timeZone: "Europe/Amsterdam" });
1239
- const note = r.error_message ?? r.message ?? "";
1240
- return `${ts.padEnd(20)} ${r.event_type.padEnd(22)} ${r.status.padEnd(8)} ${(r.project_name ?? "").padEnd(40)} ${(r.target ?? "").padEnd(10)} ${(r.deployment_id ?? "").padEnd(28)} ${note}`;
1241
- });
1242
- const header = `${"WHEN".padEnd(20)} ${"EVENT".padEnd(22)} ${"STATUS".padEnd(8)} ${"PROJECT".padEnd(40)} ${"TARGET".padEnd(10)} ${"DEPLOYMENT".padEnd(28)} NOTE`;
1243
- return `${header}
1244
- ${"-".repeat(header.length)}
1245
- ${lines.join("\n")}`;
1246
- }
1247
- function formatDomainsTable(domains) {
1248
- if (domains.length === 0) return "No domains attached to this project";
1249
- const lines = domains.map((d) => {
1250
- const verified = d.verified ? "yes" : "no";
1251
- const branch = d.gitBranch ?? "";
1252
- const redirect = d.redirect ? `${d.redirect}${d.redirectStatusCode ? ` (${d.redirectStatusCode})` : ""}` : "";
1253
- return `${d.name.padEnd(45)} ${verified.padEnd(9)} ${branch.padEnd(20)} ${redirect.padEnd(35)} ${formatTimestamp(d.createdAt)}`;
1254
- });
1255
- const header = `${"DOMAIN".padEnd(45)} ${"VERIFIED".padEnd(9)} ${"GIT BRANCH".padEnd(20)} ${"REDIRECT".padEnd(35)} CREATED`;
1256
- return `${header}
1257
- ${"-".repeat(header.length)}
1258
- ${lines.join("\n")}`;
1259
- }
1260
- function formatDomainStatus(domain, config) {
1261
- const lines = [];
1262
- lines.push(`Domain: ${domain.name}`);
1263
- lines.push(`Verified: ${domain.verified ? "yes" : "no"}`);
1264
- if (config) {
1265
- lines.push(`Misconfigured: ${config.misconfigured ? "yes" : "no"}`);
1266
- if (config.configuredBy) lines.push(`Configured by: ${config.configuredBy}`);
1267
- }
1268
- if (domain.gitBranch) lines.push(`Git branch: ${domain.gitBranch}`);
1269
- if (domain.redirect) {
1270
- lines.push(
1271
- `Redirect: ${domain.redirect}${domain.redirectStatusCode ? ` (${domain.redirectStatusCode})` : ""}`
1272
- );
1273
- }
1274
- if (domain.verification && domain.verification.length > 0) {
1275
- lines.push("");
1276
- lines.push("Required DNS records to verify ownership:");
1277
- for (const v of domain.verification) {
1278
- lines.push(` ${v.type.padEnd(6)} ${v.domain.padEnd(45)} ${v.value}${v.reason ? ` // ${v.reason}` : ""}`);
1279
- }
1280
- }
1281
- return lines.join("\n");
1282
- }
1283
- async function handleVercelTool(name, args2, deps) {
1284
- switch (name) {
1285
- case "vercel-projects": {
1286
- const token = await getVercelToken(deps);
1287
- const limit = Math.min(Math.max(Number(args2.limit) || 50, 1), 200);
1288
- const search = typeof args2.search === "string" ? args2.search.toLowerCase() : null;
1289
- const { projects, error } = await listVercelProjectsAll(token, limit);
1290
- if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
1291
- const filtered = search ? projects.filter((p) => (p.name || "").toLowerCase().includes(search)) : projects;
1292
- return { content: [{ type: "text", text: formatProjectsTable(filtered) }] };
1293
- }
1294
- case "vercel-deployments": {
1295
- const token = await getVercelToken(deps);
1296
- const projectInput = String(args2.project);
1297
- const limit = Math.min(Math.max(Number(args2.limit) || 20, 1), 100);
1298
- const projectId = await resolveProjectId(token, projectInput);
1299
- const { deployments, error } = await listVercelDeployments(token, {
1300
- projectId,
1301
- limit,
1302
- state: args2.state ? String(args2.state) : void 0,
1303
- target: args2.target ? String(args2.target) : void 0
1304
- });
1305
- if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
1306
- return { content: [{ type: "text", text: formatDeploymentsTable(deployments) }] };
1307
- }
1308
- case "vercel-logs": {
1309
- const kind = (args2.kind ? String(args2.kind) : "build").toLowerCase();
1310
- if (kind === "build") {
1311
- if (!args2.deploymentId) {
1312
- return { content: [{ type: "text", text: 'Error: kind="build" requires deploymentId.' }] };
1313
- }
1314
- const token = await getVercelToken(deps);
1315
- const deploymentId = String(args2.deploymentId);
1316
- const limit = Math.min(Math.max(Number(args2.limit) || 500, 1), 5e3);
1317
- const { events, error } = await getDeploymentBuildEvents(token, deploymentId, limit);
1318
- if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
1319
- return { content: [{ type: "text", text: formatBuildEvents(events) }] };
1320
- }
1321
- if (kind === "runtime") {
1322
- if (!args2.project || !args2.deploymentId) {
1323
- return {
1324
- content: [
1325
- { type: "text", text: 'Error: kind="runtime" requires both project and deploymentId.' }
1326
- ]
1327
- };
1328
- }
1329
- const token = await getVercelToken(deps);
1330
- const projectInput = String(args2.project);
1331
- const deploymentId = String(args2.deploymentId);
1332
- const limit = Math.min(Math.max(Number(args2.limit) || 200, 1), 1e3);
1333
- const sinceMinutesRaw = Number(args2.sinceMinutes);
1334
- const sinceExplicit = Number.isFinite(sinceMinutesRaw) && sinceMinutesRaw > 0;
1335
- const [projectId, deploymentCreatedMs] = await Promise.all([
1336
- resolveProjectId(token, projectInput),
1337
- sinceExplicit ? Promise.resolve(null) : getDeploymentCreatedMs(token, deploymentId)
1338
- ]);
1339
- const maxWindowMin = 7 * 24 * 60;
1340
- const autoCapMin = 30;
1341
- let sinceMs;
1342
- let windowNote = "";
1343
- if (sinceExplicit) {
1344
- const capped = Math.min(sinceMinutesRaw, maxWindowMin);
1345
- sinceMs = Date.now() - capped * 6e4;
1346
- windowNote = `window: last ${capped} min (caller-specified)`;
1347
- } else if (deploymentCreatedMs) {
1348
- const bufferMs = 5 * 6e4;
1349
- const sinceDeploymentMs = deploymentCreatedMs - bufferMs;
1350
- const ageMin = Math.max(1, Math.round((Date.now() - sinceDeploymentMs) / 6e4));
1351
- if (ageMin <= autoCapMin) {
1352
- sinceMs = sinceDeploymentMs;
1353
- windowNote = `window: auto ${ageMin} min from deployment createdAt - 5 min buffer`;
1354
- } else {
1355
- sinceMs = Date.now() - autoCapMin * 6e4;
1356
- windowNote = `window: capped to last ${autoCapMin} min (deployment is ${ageMin} min old \u2014 pass sinceMinutes to widen up to ${maxWindowMin})`;
1357
- }
1358
- } else {
1359
- sinceMs = Date.now() - autoCapMin * 6e4;
1360
- windowNote = `window: last ${autoCapMin} min (deployment metadata unavailable, used fallback)`;
1361
- }
1362
- const { logs, error } = await getRuntimeLogs(
1363
- token,
1364
- projectId,
1365
- deploymentId,
1366
- limit,
1367
- sinceMs
1368
- );
1369
- if (error) {
1370
- const hint = error.includes("404") || error.includes("400") ? '\n\nThis endpoint requires both project ID and deployment ID and may not be available on every Vercel plan. Use kind="webhooks" or query the vercel_deployment_log table directly for archived runtime logs.' : "";
1371
- return { content: [{ type: "text", text: `Error: ${error}${hint}` }] };
1372
- }
1373
- const body = formatRuntimeLogs(logs);
1374
- const hitDurationLimit = /Exceeded query duration limit/i.test(body);
1375
- const footer = hitDurationLimit ? `
1376
-
1377
- [${windowNote}]
1378
- [hint] Vercel hit its 5-min query budget for this window. Try a smaller sinceMinutes (e.g. 5-10), lower limit, or use kind="webhooks" / query the vercel_deployment_log table for archived logs.` : `
1379
-
1380
- [${windowNote}]`;
1381
- return { content: [{ type: "text", text: body + footer }] };
1382
- }
1383
- if (kind === "webhooks") {
1384
- const limit = Math.min(Math.max(Number(args2.limit) || 25, 1), 200);
1385
- const conds = [sql`TRUE`];
1386
- if (args2.projectName) conds.push(sql`project_name = ${String(args2.projectName)}`);
1387
- if (args2.status) conds.push(sql`status = ${String(args2.status)}`);
1388
- const where = sql.join(conds, sql` AND `);
1389
- try {
1390
- const rows = await deps.db.execute(sql`
1391
- SELECT id, event_type, status, project_name, deployment_id, target,
1392
- message, error_message, created_at
1393
- FROM vercel_webhook_logs
1394
- WHERE ${where}
1395
- ORDER BY created_at DESC
1396
- LIMIT ${limit}
1397
- `);
1398
- return {
1399
- content: [{ type: "text", text: formatWebhookHistory([...rows]) }]
1400
- };
1401
- } catch (err) {
1402
- const msg = err instanceof Error ? err.message : String(err);
1403
- return { content: [{ type: "text", text: `Error: ${msg}` }] };
1404
- }
1405
- }
1406
- return {
1407
- content: [
1408
- { type: "text", text: `Error: unknown kind "${kind}". Use build, runtime, or webhooks.` }
1409
- ]
1410
- };
1411
- }
1412
- case "vercel-domains": {
1413
- const action = (args2.action ? String(args2.action) : "list").toLowerCase();
1414
- if (!args2.project) {
1415
- return { content: [{ type: "text", text: 'Error: vercel-domains requires "project".' }] };
1416
- }
1417
- const token = await getVercelToken(deps);
1418
- const projectInput = String(args2.project);
1419
- const projectId = await resolveProjectId(token, projectInput);
1420
- if (action === "list") {
1421
- const { domains, error } = await listProjectDomains(token, projectId);
1422
- if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
1423
- return { content: [{ type: "text", text: formatDomainsTable(domains) }] };
1424
- }
1425
- if (action === "add") {
1426
- if (!args2.domain) {
1427
- return { content: [{ type: "text", text: 'Error: action="add" requires "domain".' }] };
1428
- }
1429
- const body = { name: String(args2.domain) };
1430
- if (args2.gitBranch) body.gitBranch = String(args2.gitBranch);
1431
- if (args2.redirect) body.redirect = String(args2.redirect);
1432
- if (args2.redirectStatusCode) body.redirectStatusCode = Number(args2.redirectStatusCode);
1433
- const { domain, error } = await addProjectDomain(token, projectId, body);
1434
- if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
1435
- if (!domain) {
1436
- return { content: [{ type: "text", text: `Domain ${body.name} added (no detail returned).` }] };
1437
- }
1438
- const { config } = await getDomainConfig(token, domain.name);
1439
- const status = formatDomainStatus(domain, config);
1440
- const headline = domain.verified ? `Domain ${domain.name} added and verified.` : `Domain ${domain.name} added. DNS verification still pending.`;
1441
- return { content: [{ type: "text", text: `${headline}
1442
-
1443
- ${status}` }] };
1444
- }
1445
- if (action === "verify") {
1446
- if (!args2.domain) {
1447
- return { content: [{ type: "text", text: 'Error: action="verify" requires "domain".' }] };
1448
- }
1449
- const domainName = String(args2.domain);
1450
- const { domain, error } = await getProjectDomain(token, projectId, domainName);
1451
- if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
1452
- if (!domain) {
1453
- return { content: [{ type: "text", text: `Domain ${domainName} not found on this project.` }] };
1454
- }
1455
- const { config } = await getDomainConfig(token, domainName);
1456
- return { content: [{ type: "text", text: formatDomainStatus(domain, config) }] };
1457
- }
1458
- if (action === "remove") {
1459
- if (!args2.domain) {
1460
- return { content: [{ type: "text", text: 'Error: action="remove" requires "domain".' }] };
1461
- }
1462
- const domainName = String(args2.domain);
1463
- const { error } = await removeProjectDomain(token, projectId, domainName);
1464
- if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
1465
- return { content: [{ type: "text", text: `Domain ${domainName} removed from project.` }] };
1466
- }
1467
- return {
1468
- content: [
1469
- { type: "text", text: `Error: unknown action "${action}". Use list, add, verify, or remove.` }
1470
- ]
1471
- };
1472
- }
1473
- default:
1474
- return { content: [{ type: "text", text: `Unknown vercel tool: ${name}` }] };
1475
- }
1476
- }
1477
862
  var REPO_TOOLS = [
1478
863
  {
1479
864
  name: "repo-list",
@@ -2343,7 +1728,6 @@ var TOOL_MODULE_MAP = {
2343
1728
  "dns-list": "domains",
2344
1729
  "dns-record": "domains",
2345
1730
  ...TRIGGER_TOOL_MODULE_MAP,
2346
- ...VERCEL_TOOL_MODULE_MAP,
2347
1731
  ...REPO_TOOL_MODULE_MAP
2348
1732
  };
2349
1733
  var authContext = null;
@@ -2700,151 +2084,6 @@ function decrypt(payload) {
2700
2084
  decrypted += decipher.final("utf8");
2701
2085
  return decrypted;
2702
2086
  }
2703
- var VERCEL_API2 = "https://api.vercel.com";
2704
- function parseEnvContent(content) {
2705
- const result = {};
2706
- for (const line of content.split("\n")) {
2707
- const trimmed = line.trim();
2708
- if (!trimmed || trimmed.startsWith("#")) continue;
2709
- const eqIdx = trimmed.indexOf("=");
2710
- if (eqIdx === -1) continue;
2711
- const key = trimmed.slice(0, eqIdx).trim();
2712
- let value = trimmed.slice(eqIdx + 1).trim();
2713
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
2714
- value = value.slice(1, -1);
2715
- }
2716
- if (key) result[key] = value;
2717
- }
2718
- return result;
2719
- }
2720
- function stageToVercelTargets(stageType, customEnvId) {
2721
- if (stageType === "prod") return { target: ["production"] };
2722
- if (customEnvId) return { customEnvironmentIds: [customEnvId] };
2723
- return { target: ["preview"] };
2724
- }
2725
- function getVercelEnvSyncTargetings(stageType, customEnvId) {
2726
- const primary = stageToVercelTargets(stageType, customEnvId);
2727
- if (stageType !== "dev") return [primary];
2728
- return [primary, { target: ["development"] }];
2729
- }
2730
- async function syncEnvVarsToVercel(token, projectId, envVars) {
2731
- if (envVars.length === 0) return { created: 0, error: null };
2732
- const res = await fetch(
2733
- `${VERCEL_API2}/v10/projects/${encodeURIComponent(projectId)}/env?upsert=true`,
2734
- {
2735
- method: "POST",
2736
- headers: {
2737
- Authorization: `Bearer ${token}`,
2738
- "Content-Type": "application/json"
2739
- },
2740
- body: JSON.stringify(envVars)
2741
- }
2742
- );
2743
- if (!res.ok) {
2744
- const body = await res.text().catch(() => "");
2745
- return { created: 0, error: `Vercel API ${res.status}: ${body}` };
2746
- }
2747
- const data = await res.json().catch(() => ({}));
2748
- return { created: data?.created?.length ?? envVars.length, error: null };
2749
- }
2750
- async function attemptVercelSync(appName, environment, knownStageId) {
2751
- try {
2752
- let stageId = knownStageId;
2753
- if (!stageId) {
2754
- const directRows = await db.execute(sql`
2755
- SELECT release_profile_stage_id
2756
- FROM env_config
2757
- WHERE app_name = ${appName} AND release_profile_stage_id IS NOT NULL
2758
- LIMIT 1
2759
- `);
2760
- stageId = directRows[0]?.release_profile_stage_id;
2761
- }
2762
- if (!stageId) return "Vercel sync skipped: no stage link found";
2763
- const settingsRows = await db.execute(sql`
2764
- SELECT vercel_token_encrypted FROM app_setting LIMIT 1
2765
- `);
2766
- const settings = settingsRows[0];
2767
- if (!settings?.vercel_token_encrypted) return "Vercel sync skipped: no Vercel token configured";
2768
- let token;
2769
- try {
2770
- token = decrypt(settings.vercel_token_encrypted);
2771
- } catch {
2772
- return "Vercel sync failed: could not decrypt Vercel token";
2773
- }
2774
- const stageRows = await db.execute(sql`
2775
- SELECT id, stage, stage_apps FROM release_profile_stage
2776
- WHERE id = ${stageId}
2777
- LIMIT 1
2778
- `);
2779
- const stage = stageRows[0];
2780
- if (!stage) return "Vercel sync skipped: stage not found";
2781
- const stageType = stage.stage;
2782
- const stageApps = stage.stage_apps || [];
2783
- const vercelApps = stageApps.filter(
2784
- (a) => a.deployMethod === "vercel" && a.enabled && a.vercelProjectId
2785
- );
2786
- if (vercelApps.length === 0) return "Vercel sync skipped: no Vercel apps in stage";
2787
- const envConfigs = await db.execute(sql`
2788
- SELECT id, app_name, environment, variant, env_data_encrypted, release_profile_stage_id
2789
- FROM env_config
2790
- WHERE release_profile_stage_id = ${stageId}
2791
- `);
2792
- if (envConfigs.length === 0) return "Vercel sync skipped: no env configs for stage";
2793
- const variantMap = {
2794
- dev: "development",
2795
- staging: "staging",
2796
- prod: "production"
2797
- };
2798
- const deployedVariant = variantMap[stageType] ?? stageType;
2799
- const syncResults = [];
2800
- for (const app of vercelApps) {
2801
- const name = app.path.replace("apps/", "");
2802
- const config = envConfigs.find(
2803
- (c) => c.app_name === name && c.variant === deployedVariant
2804
- );
2805
- if (!config) {
2806
- syncResults.push(`${app.label}: skipped (no config for variant "${deployedVariant}")`);
2807
- continue;
2808
- }
2809
- let envContent;
2810
- try {
2811
- envContent = decrypt(config.env_data_encrypted);
2812
- } catch {
2813
- syncResults.push(`${app.label}: decrypt failed`);
2814
- continue;
2815
- }
2816
- const pairs = parseEnvContent(envContent);
2817
- const keys = Object.keys(pairs);
2818
- if (keys.length === 0) {
2819
- syncResults.push(`${app.label}: empty config`);
2820
- continue;
2821
- }
2822
- const targetings = getVercelEnvSyncTargetings(stageType, app.vercelCustomEnvId);
2823
- let createdTotal = 0;
2824
- let lastErr = null;
2825
- for (const targeting of targetings) {
2826
- const envVars = keys.map((key) => ({
2827
- key,
2828
- value: pairs[key],
2829
- type: "encrypted",
2830
- ...targeting
2831
- }));
2832
- const { created, error } = await syncEnvVarsToVercel(token, app.vercelProjectId, envVars);
2833
- if (error) lastErr = error;
2834
- else createdTotal += created;
2835
- }
2836
- if (lastErr) {
2837
- syncResults.push(`${app.label}: FAILED - ${lastErr}`);
2838
- } else {
2839
- syncResults.push(`${app.label}: ${createdTotal} var upsert(s) synced`);
2840
- }
2841
- }
2842
- return `Vercel sync: ${syncResults.join("; ")}`;
2843
- } catch (err) {
2844
- const msg = err instanceof Error ? err.message : String(err);
2845
- return `Vercel sync error: ${msg}`;
2846
- }
2847
- }
2848
2087
  function posixQuote(arg) {
2849
2088
  if (arg === "") return "''";
2850
2089
  if (/^[A-Za-z0-9._\/=:@%+\-]+$/.test(arg)) return arg;
@@ -4473,11 +3712,11 @@ CREATE TABLE IF NOT EXISTS _mcp_migrations (
4473
3712
  applied_by TEXT
4474
3713
  );
4475
3714
  `.trim();
4476
- function normaliseMigrationSql(sql4) {
4477
- return sql4.replace(/\r\n/g, "\n").trim() + "\n";
3715
+ function normaliseMigrationSql(sql3) {
3716
+ return sql3.replace(/\r\n/g, "\n").trim() + "\n";
4478
3717
  }
4479
- function migrationSha256(sql4) {
4480
- return createHash("sha256").update(normaliseMigrationSql(sql4), "utf8").digest("hex");
3718
+ function migrationSha256(sql3) {
3719
+ return createHash("sha256").update(normaliseMigrationSql(sql3), "utf8").digest("hex");
4481
3720
  }
4482
3721
  function dollarQuoteTag(value) {
4483
3722
  let tag = "_mcp";
@@ -4685,7 +3924,7 @@ async function mijnhostFetch(path, options = {}) {
4685
3924
  "API-Key": key,
4686
3925
  "Accept": "application/json",
4687
3926
  "Content-Type": "application/json",
4688
- "User-Agent": "mg-dashboard-mcp/2.2.0",
3927
+ "User-Agent": "mg-dashboard-mcp/6.6.0",
4689
3928
  ...options.headers || {}
4690
3929
  }
4691
3930
  });
@@ -4696,6 +3935,36 @@ async function mijnhostFetch(path, options = {}) {
4696
3935
  }
4697
3936
  return body;
4698
3937
  }
3938
+ var DNS_MIN_INTERVAL_MS = 750;
3939
+ var DNS_RETRY_BACKOFF_MS = 1500;
3940
+ var dnsZoneQueue = /* @__PURE__ */ new Map();
3941
+ var dnsLastPutAt = /* @__PURE__ */ new Map();
3942
+ function sleep(ms) {
3943
+ return new Promise((resolve) => setTimeout(resolve, ms));
3944
+ }
3945
+ async function withDnsZoneLock(domain, fn) {
3946
+ const key = domain.toLowerCase();
3947
+ const previous = dnsZoneQueue.get(key) ?? Promise.resolve();
3948
+ const run = previous.then(async () => {
3949
+ const last = dnsLastPutAt.get(key) ?? 0;
3950
+ const wait = DNS_MIN_INTERVAL_MS - (Date.now() - last);
3951
+ if (wait > 0) await sleep(wait);
3952
+ return fn();
3953
+ });
3954
+ const queued = run.finally(() => {
3955
+ if (dnsZoneQueue.get(key) === queued) dnsZoneQueue.delete(key);
3956
+ });
3957
+ dnsZoneQueue.set(key, queued.catch(() => {
3958
+ }));
3959
+ return run;
3960
+ }
3961
+ function markDnsPut(domain) {
3962
+ dnsLastPutAt.set(domain.toLowerCase(), Date.now());
3963
+ }
3964
+ function isTransientMijnhostError(err) {
3965
+ const msg = err instanceof Error ? err.message : String(err);
3966
+ return /Internal Server Error|API error: 5\d\d|fetch failed|ETIMEDOUT|ECONNRESET/i.test(msg);
3967
+ }
4699
3968
  var TOOLS = [
4700
3969
  {
4701
3970
  name: "list-servers",
@@ -5059,7 +4328,7 @@ var TOOLS = [
5059
4328
  },
5060
4329
  {
5061
4330
  name: "dns-record",
5062
- description: 'Mutate a single DNS record on a mijn.host domain. Pick the mutation with `action`:\n- "create": add a new record. Required: type, name, value. Optional: ttl (default 3600).\n- "update": replace an existing record. Required: type, name, oldValue, newValue. Optional: ttl.\n- "delete": remove a record. Required: type, name, value.\n\nAlways pass `dryRun: true` first when touching MX / SPF / DKIM / DMARC \u2014 returns a full before/after diff with a mail-auth warning and applies nothing. Re-run without dryRun once the diff looks correct.\n\nUse `dns-list` to inspect the current zone first if you need to identify the right `oldValue`. Requires MIJNHOST_API_KEY.',
4331
+ description: 'Mutate a single DNS record on a mijn.host domain. Pick the mutation with `action`:\n- "create": add a new record. Required: type, name, value. Optional: ttl (default 3600).\n- "update": replace an existing record. Required: type, name, oldValue, newValue. Optional: ttl.\n- "delete": remove a record. Required: type, name, value.\n\nAlways pass `dryRun: true` first when touching MX / SPF / DKIM / DMARC \u2014 returns a full before/after diff with a mail-auth warning and applies nothing. Re-run without dryRun once the diff looks correct.\n\nUse `dns-list` to inspect the current zone first if you need to identify the right `oldValue`. Requires MIJNHOST_API_KEY.\n\nConcurrency: calls are automatically serialized per domain (mijn.host\'s API does GET-then-PUT-the-whole-zone, so parallel calls would race and silently drop records). A minimum 750 ms gap is enforced between PUTs to the same zone, transient 5xx errors are retried once, and a stale-snapshot guard refuses to PUT a zone that suddenly shrunk. You can still fire calls in parallel \u2014 they just queue safely behind each other per domain.',
5063
4332
  inputSchema: {
5064
4333
  type: "object",
5065
4334
  properties: {
@@ -5078,12 +4347,10 @@ var TOOLS = [
5078
4347
  },
5079
4348
  // ----- Trigger.dev -----
5080
4349
  ...TRIGGER_TOOLS,
5081
- // ----- Vercel -----
5082
- ...VERCEL_TOOLS,
5083
4350
  // ----- Repo reference -----
5084
4351
  ...REPO_TOOLS
5085
4352
  ];
5086
- var MCP_VERSION = "6.3.0";
4353
+ var MCP_VERSION = "6.6.0";
5087
4354
  async function handleListTools() {
5088
4355
  if (!authContext) return { tools: TOOLS };
5089
4356
  const accessible = TOOLS.filter((tool) => {
@@ -6560,9 +5827,7 @@ LIMIT ${limit};
6560
5827
  `);
6561
5828
  saveMsg = `Stored env config: ${appName}/${environment}`;
6562
5829
  }
6563
- const syncStageId = existing?.release_profile_stage_id ?? resolvedStageIds?.[0];
6564
- const vercelStatus = await attemptVercelSync(appName, environment, syncStageId);
6565
- return { content: [{ type: "text", text: `${saveMsg}. ${vercelStatus}` }] };
5830
+ return { content: [{ type: "text", text: saveMsg }] };
6566
5831
  }
6567
5832
  // ----- Cache Purge -----
6568
5833
  case "cache-purge": {
@@ -6750,82 +6015,107 @@ ${lines.join("\n")}` }] };
6750
6015
  if (!domain || !type || !dnsName) {
6751
6016
  throw new Error("domain, type, and name are required");
6752
6017
  }
6753
- const current = await mijnhostFetch(
6754
- `/domains/${encodeURIComponent(domain)}/dns`
6755
- );
6756
- let nextRecords;
6757
- let diffArgs;
6758
- let okMessage;
6759
- if (action === "create") {
6760
- const value = typeof a.value === "string" ? a.value : "";
6761
- if (!value) throw new Error('action="create" requires value');
6762
- const ttl = Number(a.ttl) || 3600;
6763
- const newRecord = { type, name: dnsName, value, ttl };
6764
- nextRecords = [...current.data.records, newRecord];
6765
- diffArgs = { verb: "create", added: [newRecord] };
6766
- okMessage = `DNS record created: ${type} ${dnsName} \u2192 ${value} (TTL: ${ttl})`;
6767
- } else if (action === "update") {
6768
- const oldValue = typeof a.oldValue === "string" ? a.oldValue : "";
6769
- const newValue = typeof a.newValue === "string" ? a.newValue : "";
6770
- if (!oldValue || !newValue) throw new Error('action="update" requires oldValue and newValue');
6771
- const ttlArg = a.ttl !== void 0 ? Number(a.ttl) : void 0;
6772
- const idx = current.data.records.findIndex(
6773
- (r) => r.type === type && r.name === dnsName && r.value === oldValue
6018
+ return withDnsZoneLock(domain, async () => {
6019
+ const fetchZone = () => mijnhostFetch(
6020
+ `/domains/${encodeURIComponent(domain)}/dns`
6774
6021
  );
6775
- if (idx === -1) {
6776
- throw new Error(
6777
- `No matching DNS record found: ${type} ${dnsName} = ${oldValue}
6022
+ let current = await fetchZone();
6023
+ if (current.data.records.length === 0) {
6024
+ await sleep(DNS_RETRY_BACKOFF_MS);
6025
+ current = await fetchZone();
6026
+ }
6027
+ let nextRecords;
6028
+ let diffArgs;
6029
+ let okMessage;
6030
+ if (action === "create") {
6031
+ const value = typeof a.value === "string" ? a.value : "";
6032
+ if (!value) throw new Error('action="create" requires value');
6033
+ const ttl = Number(a.ttl) || 3600;
6034
+ const newRecord = { type, name: dnsName, value, ttl };
6035
+ nextRecords = [...current.data.records, newRecord];
6036
+ diffArgs = { verb: "create", added: [newRecord] };
6037
+ okMessage = `DNS record created: ${type} ${dnsName} \u2192 ${value} (TTL: ${ttl})`;
6038
+ } else if (action === "update") {
6039
+ const oldValue = typeof a.oldValue === "string" ? a.oldValue : "";
6040
+ const newValue = typeof a.newValue === "string" ? a.newValue : "";
6041
+ if (!oldValue || !newValue) throw new Error('action="update" requires oldValue and newValue');
6042
+ const ttlArg = a.ttl !== void 0 ? Number(a.ttl) : void 0;
6043
+ const idx = current.data.records.findIndex(
6044
+ (r) => r.type === type && r.name === dnsName && r.value === oldValue
6045
+ );
6046
+ if (idx === -1) {
6047
+ throw new Error(
6048
+ `No matching DNS record found: ${type} ${dnsName} = ${oldValue}
6778
6049
  ` + describeDnsCandidates(current.data.records, type, dnsName, oldValue)
6050
+ );
6051
+ }
6052
+ const updated = [...current.data.records];
6053
+ const before = updated[idx];
6054
+ const after = {
6055
+ type,
6056
+ name: dnsName,
6057
+ value: newValue,
6058
+ ttl: ttlArg ?? before.ttl
6059
+ };
6060
+ updated[idx] = after;
6061
+ nextRecords = updated;
6062
+ diffArgs = { verb: "update", removed: [before], added: [after] };
6063
+ okMessage = `DNS record updated: ${type} ${dnsName} \u2192 ${newValue}${ttlArg ? ` (TTL: ${ttlArg})` : ""}`;
6064
+ } else {
6065
+ const value = typeof a.value === "string" ? a.value : "";
6066
+ if (!value) throw new Error('action="delete" requires value');
6067
+ const removed = current.data.records.filter(
6068
+ (r) => r.type === type && r.name === dnsName && r.value === value
6779
6069
  );
6070
+ const remaining = current.data.records.filter(
6071
+ (r) => !(r.type === type && r.name === dnsName && r.value === value)
6072
+ );
6073
+ if (removed.length === 0) {
6074
+ throw new Error(
6075
+ `No matching DNS record found: ${type} ${dnsName} = ${value}
6076
+ ` + describeDnsCandidates(current.data.records, type, dnsName, value)
6077
+ );
6078
+ }
6079
+ nextRecords = remaining;
6080
+ diffArgs = { verb: "delete", removed };
6081
+ okMessage = `DNS record deleted: ${type} ${dnsName} = ${value} (${remaining.length} records remaining)`;
6780
6082
  }
6781
- const updated = [...current.data.records];
6782
- const before = updated[idx];
6783
- const after = {
6784
- type,
6785
- name: dnsName,
6786
- value: newValue,
6787
- ttl: ttlArg ?? before.ttl
6788
- };
6789
- updated[idx] = after;
6790
- nextRecords = updated;
6791
- diffArgs = { verb: "update", removed: [before], added: [after] };
6792
- okMessage = `DNS record updated: ${type} ${dnsName} \u2192 ${newValue}${ttlArg ? ` (TTL: ${ttlArg})` : ""}`;
6793
- } else {
6794
- const value = typeof a.value === "string" ? a.value : "";
6795
- if (!value) throw new Error('action="delete" requires value');
6796
- const removed = current.data.records.filter(
6797
- (r) => r.type === type && r.name === dnsName && r.value === value
6798
- );
6799
- const remaining = current.data.records.filter(
6800
- (r) => !(r.type === type && r.name === dnsName && r.value === value)
6801
- );
6802
- if (removed.length === 0) {
6083
+ if (dryRun) {
6084
+ const diff = formatDnsDiff(domain, current.data.records, nextRecords, diffArgs);
6085
+ return { content: [{ type: "text", text: diff }] };
6086
+ }
6087
+ if (nextRecords.length < 3 && current.data.records.length >= 5 && action !== "delete") {
6803
6088
  throw new Error(
6804
- `No matching DNS record found: ${type} ${dnsName} = ${value}
6805
- ` + describeDnsCandidates(current.data.records, type, dnsName, value)
6089
+ `Aborting PUT: would shrink ${domain} from ${current.data.records.length} to ${nextRecords.length} records. This usually indicates a stale zone snapshot from mijn.host. Run dns-list and retry.`
6806
6090
  );
6807
6091
  }
6808
- nextRecords = remaining;
6809
- diffArgs = { verb: "delete", removed };
6810
- okMessage = `DNS record deleted: ${type} ${dnsName} = ${value} (${remaining.length} records remaining)`;
6811
- }
6812
- if (dryRun) {
6813
- const diff = formatDnsDiff(domain, current.data.records, nextRecords, diffArgs);
6814
- return { content: [{ type: "text", text: diff }] };
6815
- }
6816
- await mijnhostFetch(`/domains/${encodeURIComponent(domain)}/dns`, {
6817
- method: "PUT",
6818
- body: JSON.stringify({ records: nextRecords })
6092
+ try {
6093
+ await mijnhostFetch(`/domains/${encodeURIComponent(domain)}/dns`, {
6094
+ method: "PUT",
6095
+ body: JSON.stringify({ records: nextRecords })
6096
+ });
6097
+ } catch (err) {
6098
+ if (!isTransientMijnhostError(err)) throw err;
6099
+ await sleep(DNS_RETRY_BACKOFF_MS);
6100
+ const refetched = await fetchZone();
6101
+ const stillNeedsApply = action === "create" ? !refetched.data.records.some((r) => r.type === type && r.name === dnsName && r.value === (typeof a.value === "string" ? a.value : "")) : true;
6102
+ if (!stillNeedsApply) {
6103
+ markDnsPut(domain);
6104
+ return { content: [{ type: "text", text: `${okMessage} (already applied; recovered from transient error)` }] };
6105
+ }
6106
+ await mijnhostFetch(`/domains/${encodeURIComponent(domain)}/dns`, {
6107
+ method: "PUT",
6108
+ body: JSON.stringify({ records: nextRecords })
6109
+ });
6110
+ }
6111
+ markDnsPut(domain);
6112
+ return { content: [{ type: "text", text: okMessage }] };
6819
6113
  });
6820
- return { content: [{ type: "text", text: okMessage }] };
6821
6114
  }
6822
6115
  default:
6823
6116
  if (TRIGGER_TOOL_NAMES.has(name)) {
6824
6117
  return handleTriggerTool(name, a, { sshExec, getServerConnection });
6825
6118
  }
6826
- if (VERCEL_TOOL_NAMES.has(name)) {
6827
- return handleVercelTool(name, a, { db, decrypt });
6828
- }
6829
6119
  if (REPO_TOOL_NAMES.has(name)) {
6830
6120
  return handleRepoTool(name, a, { db, sshExec, getServerConnection });
6831
6121
  }