@mgsoftwarebv/mg-dashboard-mcp 4.0.0 → 5.0.1
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 +455 -60
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -296,16 +296,21 @@ var TRIGGER_TOOLS = [
|
|
|
296
296
|
},
|
|
297
297
|
{
|
|
298
298
|
name: "trigger-runs",
|
|
299
|
-
description: "List recent task runs for a Trigger.dev project. Returns run ID, task name, status, duration, and timestamps.",
|
|
299
|
+
description: "List recent task runs for a Trigger.dev project. Returns run ID, task name, status, duration, and timestamps.\n\nCommon shortcuts: `failedOnly: true` returns only FAILED/CRASHED/SYSTEM_FAILURE/INTERRUPTED/TIMED_OUT. `sinceMinutes: 60` filters to runs created in the last hour (combines with status/taskIdentifier).",
|
|
300
300
|
inputSchema: {
|
|
301
301
|
type: "object",
|
|
302
302
|
properties: {
|
|
303
303
|
project: { type: "string", description: 'Project slug from trigger-list (e.g. "mg-dashboard-bHfS")' },
|
|
304
304
|
status: {
|
|
305
305
|
type: "string",
|
|
306
|
-
description: "Comma-separated status filter: QUEUED,EXECUTING,COMPLETED,FAILED,CRASHED,CANCELED,SYSTEM_FAILURE"
|
|
306
|
+
description: "Comma-separated status filter: QUEUED,EXECUTING,COMPLETED,FAILED,CRASHED,CANCELED,SYSTEM_FAILURE. Ignored when failedOnly is true."
|
|
307
|
+
},
|
|
308
|
+
failedOnly: {
|
|
309
|
+
type: "boolean",
|
|
310
|
+
description: "Shortcut: only return FAILED + CRASHED + SYSTEM_FAILURE + INTERRUPTED + TIMED_OUT runs. Overrides `status` when true."
|
|
307
311
|
},
|
|
308
312
|
taskIdentifier: { type: "string", description: 'Filter by task identifier (e.g. "hello-world")' },
|
|
313
|
+
sinceMinutes: { type: "number", description: "Only runs created in the last N minutes (uses Trigger filter[from]). Max 10080 (= 1 week)." },
|
|
309
314
|
limit: { type: "number", description: "Max runs to return (default 20, max 100)" }
|
|
310
315
|
},
|
|
311
316
|
required: ["project"]
|
|
@@ -489,8 +494,18 @@ ${lines.join("\n")}` }] };
|
|
|
489
494
|
const instance = await discoverInstance(project, conn, proxy, sshExec2);
|
|
490
495
|
const limit = Math.min(Math.max(Number(args2.limit) || 20, 1), 100);
|
|
491
496
|
const queryParts = [`page%5Bsize%5D=${limit}`];
|
|
492
|
-
|
|
493
|
-
|
|
497
|
+
const failedOnly = args2.failedOnly === true;
|
|
498
|
+
const statusArg = failedOnly ? "FAILED,CRASHED,SYSTEM_FAILURE,INTERRUPTED,TIMED_OUT" : args2.status ? String(args2.status) : "";
|
|
499
|
+
if (statusArg) queryParts.push(`filter%5Bstatus%5D=${encodeURIComponent(statusArg)}`);
|
|
500
|
+
if (args2.taskIdentifier) {
|
|
501
|
+
queryParts.push(`filter%5BtaskIdentifier%5D=${encodeURIComponent(String(args2.taskIdentifier))}`);
|
|
502
|
+
}
|
|
503
|
+
const sinceMinutes = Number(args2.sinceMinutes);
|
|
504
|
+
if (Number.isFinite(sinceMinutes) && sinceMinutes > 0) {
|
|
505
|
+
const capped = Math.min(sinceMinutes, 10080);
|
|
506
|
+
const fromMs = Date.now() - capped * 6e4;
|
|
507
|
+
queryParts.push(`filter%5Bfrom%5D=${fromMs}`);
|
|
508
|
+
}
|
|
494
509
|
const rawJson = await triggerApi(
|
|
495
510
|
conn,
|
|
496
511
|
proxy,
|
|
@@ -669,6 +684,41 @@ var VERCEL_TOOLS = [
|
|
|
669
684
|
required: ["project"]
|
|
670
685
|
}
|
|
671
686
|
},
|
|
687
|
+
{
|
|
688
|
+
name: "vercel-domains",
|
|
689
|
+
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.',
|
|
690
|
+
inputSchema: {
|
|
691
|
+
type: "object",
|
|
692
|
+
properties: {
|
|
693
|
+
action: {
|
|
694
|
+
type: "string",
|
|
695
|
+
enum: ["list", "add", "remove", "verify"],
|
|
696
|
+
description: "Which operation to perform (default: list)."
|
|
697
|
+
},
|
|
698
|
+
project: {
|
|
699
|
+
type: "string",
|
|
700
|
+
description: "Vercel project ID or name (from vercel-projects)."
|
|
701
|
+
},
|
|
702
|
+
domain: {
|
|
703
|
+
type: "string",
|
|
704
|
+
description: 'Domain name (required for action="add", "remove", or "verify").'
|
|
705
|
+
},
|
|
706
|
+
gitBranch: {
|
|
707
|
+
type: "string",
|
|
708
|
+
description: 'Optional git branch this domain should deploy from (action="add" only).'
|
|
709
|
+
},
|
|
710
|
+
redirect: {
|
|
711
|
+
type: "string",
|
|
712
|
+
description: 'Optional redirect target domain (action="add" only).'
|
|
713
|
+
},
|
|
714
|
+
redirectStatusCode: {
|
|
715
|
+
type: "number",
|
|
716
|
+
description: 'Optional redirect status code, e.g. 301 / 302 / 307 / 308 (action="add" only).'
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
required: ["project"]
|
|
720
|
+
}
|
|
721
|
+
},
|
|
672
722
|
{
|
|
673
723
|
name: "vercel-logs",
|
|
674
724
|
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.',
|
|
@@ -698,7 +748,7 @@ var VERCEL_TOOLS = [
|
|
|
698
748
|
},
|
|
699
749
|
sinceMinutes: {
|
|
700
750
|
type: "number",
|
|
701
|
-
description:
|
|
751
|
+
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.`
|
|
702
752
|
},
|
|
703
753
|
limit: {
|
|
704
754
|
type: "number",
|
|
@@ -712,11 +762,14 @@ var VERCEL_TOOL_NAMES = new Set(VERCEL_TOOLS.map((t) => t.name));
|
|
|
712
762
|
var VERCEL_TOOL_MODULE_MAP = {
|
|
713
763
|
"vercel-projects": "ci_cd",
|
|
714
764
|
"vercel-deployments": "ci_cd",
|
|
715
|
-
"vercel-logs": "ci_cd"
|
|
765
|
+
"vercel-logs": "ci_cd",
|
|
766
|
+
"vercel-domains": "ci_cd"
|
|
716
767
|
};
|
|
717
|
-
async function vercelFetch(token, path) {
|
|
768
|
+
async function vercelFetch(token, path, init) {
|
|
718
769
|
const res = await fetch(`${VERCEL_API}${path}`, {
|
|
719
|
-
|
|
770
|
+
method: init?.method ?? "GET",
|
|
771
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
772
|
+
body: init?.body !== void 0 ? JSON.stringify(init.body) : void 0
|
|
720
773
|
});
|
|
721
774
|
const body = await res.text();
|
|
722
775
|
let parsed = null;
|
|
@@ -800,6 +853,14 @@ async function getDeploymentBuildEvents(token, deploymentId, limit) {
|
|
|
800
853
|
}
|
|
801
854
|
return { events, error: null };
|
|
802
855
|
}
|
|
856
|
+
async function getDeploymentCreatedMs(token, deploymentId) {
|
|
857
|
+
const res = await vercelFetch(
|
|
858
|
+
token,
|
|
859
|
+
`/v13/deployments/${encodeURIComponent(deploymentId)}`
|
|
860
|
+
);
|
|
861
|
+
if (res.error || !res.data) return null;
|
|
862
|
+
return res.data.createdAt ?? res.data.created ?? null;
|
|
863
|
+
}
|
|
803
864
|
async function getRuntimeLogs(token, projectIdOrName, deploymentId, limit, sinceMs) {
|
|
804
865
|
const params = new URLSearchParams({
|
|
805
866
|
limit: String(Math.min(Math.max(limit, 1), 1e3))
|
|
@@ -840,6 +901,47 @@ async function getRuntimeLogs(token, projectIdOrName, deploymentId, limit, since
|
|
|
840
901
|
}
|
|
841
902
|
return { logs, error: null };
|
|
842
903
|
}
|
|
904
|
+
async function listProjectDomains(token, projectId) {
|
|
905
|
+
const res = await vercelFetch(
|
|
906
|
+
token,
|
|
907
|
+
`/v9/projects/${encodeURIComponent(projectId)}/domains?limit=100`
|
|
908
|
+
);
|
|
909
|
+
if (res.error) return { domains: [], error: res.error };
|
|
910
|
+
return { domains: res.data?.domains ?? [], error: null };
|
|
911
|
+
}
|
|
912
|
+
async function addProjectDomain(token, projectId, body) {
|
|
913
|
+
const res = await vercelFetch(
|
|
914
|
+
token,
|
|
915
|
+
`/v10/projects/${encodeURIComponent(projectId)}/domains`,
|
|
916
|
+
{ method: "POST", body }
|
|
917
|
+
);
|
|
918
|
+
if (res.error) return { domain: null, error: res.error };
|
|
919
|
+
return { domain: res.data, error: null };
|
|
920
|
+
}
|
|
921
|
+
async function removeProjectDomain(token, projectId, domain) {
|
|
922
|
+
const res = await vercelFetch(
|
|
923
|
+
token,
|
|
924
|
+
`/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}`,
|
|
925
|
+
{ method: "DELETE" }
|
|
926
|
+
);
|
|
927
|
+
return { error: res.error };
|
|
928
|
+
}
|
|
929
|
+
async function getProjectDomain(token, projectId, domain) {
|
|
930
|
+
const res = await vercelFetch(
|
|
931
|
+
token,
|
|
932
|
+
`/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}`
|
|
933
|
+
);
|
|
934
|
+
if (res.error) return { domain: null, error: res.error };
|
|
935
|
+
return { domain: res.data, error: null };
|
|
936
|
+
}
|
|
937
|
+
async function getDomainConfig(token, domain) {
|
|
938
|
+
const res = await vercelFetch(
|
|
939
|
+
token,
|
|
940
|
+
`/v6/domains/${encodeURIComponent(domain)}/config`
|
|
941
|
+
);
|
|
942
|
+
if (res.error) return { config: null, error: res.error };
|
|
943
|
+
return { config: res.data, error: null };
|
|
944
|
+
}
|
|
843
945
|
async function getVercelToken(deps) {
|
|
844
946
|
const { data, error } = await deps.supabase.from("app_setting").select("vercel_token_encrypted").maybeSingle();
|
|
845
947
|
if (error) throw new Error(`Could not read app_setting: ${error.message}`);
|
|
@@ -935,6 +1037,42 @@ function formatWebhookHistory(rows) {
|
|
|
935
1037
|
${"-".repeat(header.length)}
|
|
936
1038
|
${lines.join("\n")}`;
|
|
937
1039
|
}
|
|
1040
|
+
function formatDomainsTable(domains) {
|
|
1041
|
+
if (domains.length === 0) return "No domains attached to this project";
|
|
1042
|
+
const lines = domains.map((d) => {
|
|
1043
|
+
const verified = d.verified ? "yes" : "no";
|
|
1044
|
+
const branch = d.gitBranch ?? "";
|
|
1045
|
+
const redirect = d.redirect ? `${d.redirect}${d.redirectStatusCode ? ` (${d.redirectStatusCode})` : ""}` : "";
|
|
1046
|
+
return `${d.name.padEnd(45)} ${verified.padEnd(9)} ${branch.padEnd(20)} ${redirect.padEnd(35)} ${formatTimestamp(d.createdAt)}`;
|
|
1047
|
+
});
|
|
1048
|
+
const header = `${"DOMAIN".padEnd(45)} ${"VERIFIED".padEnd(9)} ${"GIT BRANCH".padEnd(20)} ${"REDIRECT".padEnd(35)} CREATED`;
|
|
1049
|
+
return `${header}
|
|
1050
|
+
${"-".repeat(header.length)}
|
|
1051
|
+
${lines.join("\n")}`;
|
|
1052
|
+
}
|
|
1053
|
+
function formatDomainStatus(domain, config) {
|
|
1054
|
+
const lines = [];
|
|
1055
|
+
lines.push(`Domain: ${domain.name}`);
|
|
1056
|
+
lines.push(`Verified: ${domain.verified ? "yes" : "no"}`);
|
|
1057
|
+
if (config) {
|
|
1058
|
+
lines.push(`Misconfigured: ${config.misconfigured ? "yes" : "no"}`);
|
|
1059
|
+
if (config.configuredBy) lines.push(`Configured by: ${config.configuredBy}`);
|
|
1060
|
+
}
|
|
1061
|
+
if (domain.gitBranch) lines.push(`Git branch: ${domain.gitBranch}`);
|
|
1062
|
+
if (domain.redirect) {
|
|
1063
|
+
lines.push(
|
|
1064
|
+
`Redirect: ${domain.redirect}${domain.redirectStatusCode ? ` (${domain.redirectStatusCode})` : ""}`
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
if (domain.verification && domain.verification.length > 0) {
|
|
1068
|
+
lines.push("");
|
|
1069
|
+
lines.push("Required DNS records to verify ownership:");
|
|
1070
|
+
for (const v of domain.verification) {
|
|
1071
|
+
lines.push(` ${v.type.padEnd(6)} ${v.domain.padEnd(45)} ${v.value}${v.reason ? ` // ${v.reason}` : ""}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return lines.join("\n");
|
|
1075
|
+
}
|
|
938
1076
|
async function handleVercelTool(name, args2, deps) {
|
|
939
1077
|
switch (name) {
|
|
940
1078
|
case "vercel-projects": {
|
|
@@ -985,9 +1123,33 @@ async function handleVercelTool(name, args2, deps) {
|
|
|
985
1123
|
const projectInput = String(args2.project);
|
|
986
1124
|
const deploymentId = String(args2.deploymentId);
|
|
987
1125
|
const limit = Math.min(Math.max(Number(args2.limit) || 200, 1), 1e3);
|
|
988
|
-
const
|
|
989
|
-
const
|
|
990
|
-
const projectId = await
|
|
1126
|
+
const sinceMinutesRaw = Number(args2.sinceMinutes);
|
|
1127
|
+
const sinceExplicit = Number.isFinite(sinceMinutesRaw) && sinceMinutesRaw > 0;
|
|
1128
|
+
const [projectId, deploymentCreatedMs] = await Promise.all([
|
|
1129
|
+
resolveProjectId(token, projectInput),
|
|
1130
|
+
sinceExplicit ? Promise.resolve(null) : getDeploymentCreatedMs(token, deploymentId)
|
|
1131
|
+
]);
|
|
1132
|
+
const maxWindowMin = 7 * 24 * 60;
|
|
1133
|
+
let sinceMs;
|
|
1134
|
+
let windowNote = "";
|
|
1135
|
+
if (sinceExplicit) {
|
|
1136
|
+
const capped = Math.min(sinceMinutesRaw, maxWindowMin);
|
|
1137
|
+
sinceMs = Date.now() - capped * 6e4;
|
|
1138
|
+
windowNote = `window: last ${capped} min (caller-specified)`;
|
|
1139
|
+
} else if (deploymentCreatedMs) {
|
|
1140
|
+
const bufferMs = 5 * 6e4;
|
|
1141
|
+
sinceMs = deploymentCreatedMs - bufferMs;
|
|
1142
|
+
const ageMin = Math.max(1, Math.round((Date.now() - sinceMs) / 6e4));
|
|
1143
|
+
if (ageMin > maxWindowMin) {
|
|
1144
|
+
sinceMs = Date.now() - maxWindowMin * 6e4;
|
|
1145
|
+
windowNote = `window: capped to ${maxWindowMin} min (deployment is older than 7 days)`;
|
|
1146
|
+
} else {
|
|
1147
|
+
windowNote = `window: auto ${ageMin} min from deployment createdAt - 5 min buffer`;
|
|
1148
|
+
}
|
|
1149
|
+
} else {
|
|
1150
|
+
sinceMs = Date.now() - 60 * 6e4;
|
|
1151
|
+
windowNote = "window: last 60 min (deployment metadata unavailable, used fallback)";
|
|
1152
|
+
}
|
|
991
1153
|
const { logs, error } = await getRuntimeLogs(
|
|
992
1154
|
token,
|
|
993
1155
|
projectId,
|
|
@@ -999,7 +1161,10 @@ async function handleVercelTool(name, args2, deps) {
|
|
|
999
1161
|
const hint = error.includes("404") || error.includes("400") ? '\n\nThis endpoint requires both project ID and deployment ID and may not be available on every Vercel plan. Use kind="webhooks" or the supabase MCP (vercel_deployment_log table) for archived runtime logs.' : "";
|
|
1000
1162
|
return { content: [{ type: "text", text: `Error: ${error}${hint}` }] };
|
|
1001
1163
|
}
|
|
1002
|
-
|
|
1164
|
+
const text = `${formatRuntimeLogs(logs)}
|
|
1165
|
+
|
|
1166
|
+
[${windowNote}]`;
|
|
1167
|
+
return { content: [{ type: "text", text }] };
|
|
1003
1168
|
}
|
|
1004
1169
|
if (kind === "webhooks") {
|
|
1005
1170
|
const limit = Math.min(Math.max(Number(args2.limit) || 25, 1), 200);
|
|
@@ -1020,6 +1185,67 @@ async function handleVercelTool(name, args2, deps) {
|
|
|
1020
1185
|
]
|
|
1021
1186
|
};
|
|
1022
1187
|
}
|
|
1188
|
+
case "vercel-domains": {
|
|
1189
|
+
const action = (args2.action ? String(args2.action) : "list").toLowerCase();
|
|
1190
|
+
if (!args2.project) {
|
|
1191
|
+
return { content: [{ type: "text", text: 'Error: vercel-domains requires "project".' }] };
|
|
1192
|
+
}
|
|
1193
|
+
const token = await getVercelToken(deps);
|
|
1194
|
+
const projectInput = String(args2.project);
|
|
1195
|
+
const projectId = await resolveProjectId(token, projectInput);
|
|
1196
|
+
if (action === "list") {
|
|
1197
|
+
const { domains, error } = await listProjectDomains(token, projectId);
|
|
1198
|
+
if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
|
|
1199
|
+
return { content: [{ type: "text", text: formatDomainsTable(domains) }] };
|
|
1200
|
+
}
|
|
1201
|
+
if (action === "add") {
|
|
1202
|
+
if (!args2.domain) {
|
|
1203
|
+
return { content: [{ type: "text", text: 'Error: action="add" requires "domain".' }] };
|
|
1204
|
+
}
|
|
1205
|
+
const body = { name: String(args2.domain) };
|
|
1206
|
+
if (args2.gitBranch) body.gitBranch = String(args2.gitBranch);
|
|
1207
|
+
if (args2.redirect) body.redirect = String(args2.redirect);
|
|
1208
|
+
if (args2.redirectStatusCode) body.redirectStatusCode = Number(args2.redirectStatusCode);
|
|
1209
|
+
const { domain, error } = await addProjectDomain(token, projectId, body);
|
|
1210
|
+
if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
|
|
1211
|
+
if (!domain) {
|
|
1212
|
+
return { content: [{ type: "text", text: `Domain ${body.name} added (no detail returned).` }] };
|
|
1213
|
+
}
|
|
1214
|
+
const { config } = await getDomainConfig(token, domain.name);
|
|
1215
|
+
const status = formatDomainStatus(domain, config);
|
|
1216
|
+
const headline = domain.verified ? `Domain ${domain.name} added and verified.` : `Domain ${domain.name} added. DNS verification still pending.`;
|
|
1217
|
+
return { content: [{ type: "text", text: `${headline}
|
|
1218
|
+
|
|
1219
|
+
${status}` }] };
|
|
1220
|
+
}
|
|
1221
|
+
if (action === "verify") {
|
|
1222
|
+
if (!args2.domain) {
|
|
1223
|
+
return { content: [{ type: "text", text: 'Error: action="verify" requires "domain".' }] };
|
|
1224
|
+
}
|
|
1225
|
+
const domainName = String(args2.domain);
|
|
1226
|
+
const { domain, error } = await getProjectDomain(token, projectId, domainName);
|
|
1227
|
+
if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
|
|
1228
|
+
if (!domain) {
|
|
1229
|
+
return { content: [{ type: "text", text: `Domain ${domainName} not found on this project.` }] };
|
|
1230
|
+
}
|
|
1231
|
+
const { config } = await getDomainConfig(token, domainName);
|
|
1232
|
+
return { content: [{ type: "text", text: formatDomainStatus(domain, config) }] };
|
|
1233
|
+
}
|
|
1234
|
+
if (action === "remove") {
|
|
1235
|
+
if (!args2.domain) {
|
|
1236
|
+
return { content: [{ type: "text", text: 'Error: action="remove" requires "domain".' }] };
|
|
1237
|
+
}
|
|
1238
|
+
const domainName = String(args2.domain);
|
|
1239
|
+
const { error } = await removeProjectDomain(token, projectId, domainName);
|
|
1240
|
+
if (error) return { content: [{ type: "text", text: `Error: ${error}` }] };
|
|
1241
|
+
return { content: [{ type: "text", text: `Domain ${domainName} removed from project.` }] };
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
content: [
|
|
1245
|
+
{ type: "text", text: `Error: unknown action "${action}". Use list, add, verify, or remove.` }
|
|
1246
|
+
]
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1023
1249
|
default:
|
|
1024
1250
|
return { content: [{ type: "text", text: `Unknown vercel tool: ${name}` }] };
|
|
1025
1251
|
}
|
|
@@ -1186,7 +1412,6 @@ var TOOL_MODULE_MAP = {
|
|
|
1186
1412
|
"wait-for": "ssh_servers",
|
|
1187
1413
|
"db-discover": "ssh_servers",
|
|
1188
1414
|
"db-tables": "ssh_servers",
|
|
1189
|
-
"db-describe": "ssh_servers",
|
|
1190
1415
|
"db-query": "ssh_servers",
|
|
1191
1416
|
"cache-purge": "ssh_servers",
|
|
1192
1417
|
"env-list": "ci_cd",
|
|
@@ -3146,6 +3371,42 @@ var BLOCKED_SQL_PATTERNS = [
|
|
|
3146
3371
|
/\bALTER\s+TABLE\s+\w+\s+DROP\b/i,
|
|
3147
3372
|
/\bDELETE\s+FROM\s+\w+\s*$/i
|
|
3148
3373
|
];
|
|
3374
|
+
function buildDescribeSql(target, engine) {
|
|
3375
|
+
if (target === "*" || target === "") {
|
|
3376
|
+
if (engine === "postgres") {
|
|
3377
|
+
return "SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname, tablename";
|
|
3378
|
+
}
|
|
3379
|
+
if (engine === "mssql") {
|
|
3380
|
+
return "SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_SCHEMA, TABLE_NAME";
|
|
3381
|
+
}
|
|
3382
|
+
return "SHOW TABLES";
|
|
3383
|
+
}
|
|
3384
|
+
const safe = target.replace(/[^a-zA-Z0-9_]/g, "");
|
|
3385
|
+
if (!safe) throw new Error(`Invalid describe target: ${target}`);
|
|
3386
|
+
if (engine === "postgres") {
|
|
3387
|
+
return `SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name='${safe}' ORDER BY ordinal_position;
|
|
3388
|
+
SELECT indexname, indexdef FROM pg_indexes WHERE tablename='${safe}'`;
|
|
3389
|
+
}
|
|
3390
|
+
if (engine === "mssql") {
|
|
3391
|
+
return `EXEC sp_help '${safe}'`;
|
|
3392
|
+
}
|
|
3393
|
+
return `DESCRIBE \`${safe}\`; SHOW INDEX FROM \`${safe}\``;
|
|
3394
|
+
}
|
|
3395
|
+
function formatDbQueryFooter(output, appliedLimit, maxRows, explainMode) {
|
|
3396
|
+
if (explainMode) {
|
|
3397
|
+
return "\n\n[explain] Plan returned, no rows executed.";
|
|
3398
|
+
}
|
|
3399
|
+
if (!appliedLimit) return "";
|
|
3400
|
+
const rows = output.split("\n").filter((l) => l.trim() && !/^\s*[-+|]/.test(l)).length;
|
|
3401
|
+
if (rows > maxRows) {
|
|
3402
|
+
return `
|
|
3403
|
+
|
|
3404
|
+
[truncated] auto-LIMIT ${maxRows} hit \u2014 more rows available. Refine WHERE / ORDER BY or raise maxRows (max 10000).`;
|
|
3405
|
+
}
|
|
3406
|
+
return `
|
|
3407
|
+
|
|
3408
|
+
[ok] returned ${rows} row(s), under auto-LIMIT ${maxRows}.`;
|
|
3409
|
+
}
|
|
3149
3410
|
function assertSafeSql(query) {
|
|
3150
3411
|
const trimmed = query.trim();
|
|
3151
3412
|
for (const pattern of BLOCKED_SQL_PATTERNS) {
|
|
@@ -3217,6 +3478,36 @@ function requireMijnhostApiKey() {
|
|
|
3217
3478
|
}
|
|
3218
3479
|
return mijnhostApiKey;
|
|
3219
3480
|
}
|
|
3481
|
+
function formatDnsDiff(domain, before, after, change) {
|
|
3482
|
+
const fmt = (r) => `${r.type.padEnd(6)} ${r.name.padEnd(30)} ttl=${String(r.ttl).padEnd(5)} ${r.value}`;
|
|
3483
|
+
const lines = [];
|
|
3484
|
+
lines.push(`[dryRun] ${change.verb.toUpperCase()} on ${domain} \u2014 no changes applied.`);
|
|
3485
|
+
lines.push(`Records before: ${before.length} \u2192 after: ${after.length}`);
|
|
3486
|
+
if (change.removed?.length) {
|
|
3487
|
+
lines.push("", "REMOVED:");
|
|
3488
|
+
for (const r of change.removed) lines.push(` - ${fmt(r)}`);
|
|
3489
|
+
}
|
|
3490
|
+
if (change.added?.length) {
|
|
3491
|
+
lines.push("", "ADDED:");
|
|
3492
|
+
for (const r of change.added) lines.push(` + ${fmt(r)}`);
|
|
3493
|
+
}
|
|
3494
|
+
const sensitive = [
|
|
3495
|
+
...change.added ?? [],
|
|
3496
|
+
...change.removed ?? []
|
|
3497
|
+
].filter((r) => {
|
|
3498
|
+
if (r.type === "MX") return true;
|
|
3499
|
+
if (r.type === "TXT") {
|
|
3500
|
+
const v = r.value.toLowerCase();
|
|
3501
|
+
return v.includes("v=spf1") || v.includes("v=dmarc1") || v.includes("_domainkey") || r.name.toLowerCase().includes("_domainkey");
|
|
3502
|
+
}
|
|
3503
|
+
return false;
|
|
3504
|
+
});
|
|
3505
|
+
if (sensitive.length > 0) {
|
|
3506
|
+
lines.push("", "! WARNING: touches mail-auth records (MX / SPF / DMARC / DKIM). Double-check before re-running without dryRun.");
|
|
3507
|
+
}
|
|
3508
|
+
lines.push("", "Re-run without `dryRun: true` to apply.");
|
|
3509
|
+
return lines.join("\n");
|
|
3510
|
+
}
|
|
3220
3511
|
async function mijnhostFetch(path, options = {}) {
|
|
3221
3512
|
const key = requireMijnhostApiKey();
|
|
3222
3513
|
const res = await fetch(`${MIJNHOST_BASE_URL}${path}`, {
|
|
@@ -3457,35 +3748,25 @@ var TOOLS = [
|
|
|
3457
3748
|
required: ["serverId", "sitePath"]
|
|
3458
3749
|
}
|
|
3459
3750
|
},
|
|
3460
|
-
{
|
|
3461
|
-
name: "db-describe",
|
|
3462
|
-
description: "Show the structure of a database table (columns, types, keys, defaults). Credentials are auto-discovered from site config files.",
|
|
3463
|
-
inputSchema: {
|
|
3464
|
-
type: "object",
|
|
3465
|
-
properties: {
|
|
3466
|
-
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
3467
|
-
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com)" },
|
|
3468
|
-
table: { type: "string", description: "Table name" }
|
|
3469
|
-
},
|
|
3470
|
-
required: ["serverId", "sitePath", "table"]
|
|
3471
|
-
}
|
|
3472
|
-
},
|
|
3473
3751
|
{
|
|
3474
3752
|
name: "db-query",
|
|
3475
|
-
description: 'Execute a SQL query against any database. Two routing modes:\n- MySQL via `/var/www` autodiscover (default): pass `sitePath` (and `engine: "mysql"` implicitly). Credentials are read from wp-config.php, parameters.php, .env. Same behaviour as before.\n- Direct via Docker container: pass `containerName` (the container running the DB server). Engine defaults to `postgres`; pass `engine: "mysql"` or `"mssql"` to override. The query is piped via stdin so quotes / `$` / `;` are never re-interpreted.\n\nPostgres example: `{ serverId, containerName: "supabase-db", engine: "postgres", dbName: "main", dbUser: "postgres", query: "SELECT 1" }`. Defaults: dbUser=postgres, dbName=postgres.\nMSSQL example: `{ serverId, containerName: "mssql-1", engine: "mssql", dbUser: "sa", dbPass: "...", query: "SELECT 1" }`.\n\nDestructive operations (DROP, TRUNCATE, etc.) are blocked.',
|
|
3753
|
+
description: 'Execute a SQL query against any database. Two routing modes:\n- MySQL via `/var/www` autodiscover (default): pass `sitePath` (and `engine: "mysql"` implicitly). Credentials are read from wp-config.php, parameters.php, .env. Same behaviour as before.\n- Direct via Docker container: pass `containerName` (the container running the DB server). Engine defaults to `postgres`; pass `engine: "mysql"` or `"mssql"` to override. The query is piped via stdin so quotes / `$` / `;` are never re-interpreted.\n\nPostgres example: `{ serverId, containerName: "supabase-db", engine: "postgres", dbName: "main", dbUser: "postgres", query: "SELECT 1" }`. Defaults: dbUser=postgres, dbName=postgres.\nMSSQL example: `{ serverId, containerName: "mssql-1", engine: "mssql", dbUser: "sa", dbPass: "...", query: "SELECT 1" }`.\n\nResult safety: `maxRows` (default 1000, max 10000) auto-injects a `LIMIT` for SELECT queries that don\'t have one, so unbounded scans never blow up the token budget. The footer reports whether the cap was applied. Pass `maxRows: 0` to disable.\n\nDiagnostics: `explain: true` prepends EXPLAIN (postgres / mysql) or runs `SET SHOWPLAN_TEXT ON` (mssql) so you can inspect the query plan without rewriting the query.\n\nSchema introspection: pass `describe: "tablename"` to get columns + indexes (replaces the old db-describe tool, works for mysql, postgres, and mssql). Pass `describe: "*"` to list all tables in the current database. When `describe` is set, `query` is ignored.\n\nDestructive operations (DROP, TRUNCATE, etc.) are blocked.',
|
|
3476
3754
|
inputSchema: {
|
|
3477
3755
|
type: "object",
|
|
3478
3756
|
properties: {
|
|
3479
3757
|
serverId: { type: "string", description: "UUID of the SSH server" },
|
|
3480
|
-
query: { type: "string", description: "SQL query to execute" },
|
|
3758
|
+
query: { type: "string", description: "SQL query to execute (ignored when describe is set)" },
|
|
3759
|
+
describe: { type: "string", description: 'Schema introspection shortcut. Pass a table name for columns + indexes, or "*" to list all tables. Works for mysql / postgres / mssql.' },
|
|
3481
3760
|
sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
|
|
3482
3761
|
containerName: { type: "string", description: 'Docker container running the DB server (e.g. "supabase-db", "trigger-postgres"). Activates direct-query mode.' },
|
|
3483
3762
|
engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
|
|
3484
3763
|
dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
|
|
3485
3764
|
dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
|
|
3486
|
-
dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." }
|
|
3765
|
+
dbPass: { type: "string", description: "Database password (containerName mode). Required for mssql; optional for mysql; postgres uses trust auth by default." },
|
|
3766
|
+
maxRows: { type: "number", description: "Auto-inject LIMIT for unbounded SELECTs. Default 1000, max 10000, set 0 to disable." },
|
|
3767
|
+
explain: { type: "boolean", description: "Prepend EXPLAIN (postgres/mysql) or SHOWPLAN (mssql) instead of executing. Useful for slow-query diagnosis." }
|
|
3487
3768
|
},
|
|
3488
|
-
required: ["serverId"
|
|
3769
|
+
required: ["serverId"]
|
|
3489
3770
|
}
|
|
3490
3771
|
},
|
|
3491
3772
|
{
|
|
@@ -3540,8 +3821,15 @@ var TOOLS = [
|
|
|
3540
3821
|
// ----- Domains (mijn.host) -----
|
|
3541
3822
|
{
|
|
3542
3823
|
name: "domain-list",
|
|
3543
|
-
description: "List all domains from the mijn.host account. Returns domain name, status, renewal date, and tags. Requires MIJNHOST_API_KEY.",
|
|
3544
|
-
inputSchema: {
|
|
3824
|
+
description: "List all domains from the mijn.host account. Returns domain name, status, renewal date (= expiration), and tags. Requires MIJNHOST_API_KEY.\n\nPass `details: true` to also fetch DNS zone summary per domain in parallel: NS records, MX target, and presence of SPF/DMARC. Useful as a single-call overview instead of N follow-up dns-list calls. Skipped for inactive/expired domains.",
|
|
3825
|
+
inputSchema: {
|
|
3826
|
+
type: "object",
|
|
3827
|
+
properties: {
|
|
3828
|
+
details: { type: "boolean", description: "Inline DNS zone summary (NS, MX, SPF/DMARC presence) per domain. Adds N parallel API calls \u2014 use sparingly on large accounts." },
|
|
3829
|
+
concurrency: { type: "number", description: "Max concurrent DNS lookups when details=true (default 8, max 20)." }
|
|
3830
|
+
},
|
|
3831
|
+
required: []
|
|
3832
|
+
}
|
|
3545
3833
|
},
|
|
3546
3834
|
{
|
|
3547
3835
|
name: "dns-list",
|
|
@@ -3556,7 +3844,7 @@ var TOOLS = [
|
|
|
3556
3844
|
},
|
|
3557
3845
|
{
|
|
3558
3846
|
name: "dns-create",
|
|
3559
|
-
description: "Add a new DNS record to a domain. Uses PATCH to add without replacing existing records.",
|
|
3847
|
+
description: "Add a new DNS record to a domain. Uses PATCH to add without replacing existing records.\n\nPass `dryRun: true` to preview the proposed change (full before/after diff) without calling the mijn.host PUT \u2014 recommended before touching MX, SPF, DKIM, or DMARC records.",
|
|
3560
3848
|
inputSchema: {
|
|
3561
3849
|
type: "object",
|
|
3562
3850
|
properties: {
|
|
@@ -3564,14 +3852,15 @@ var TOOLS = [
|
|
|
3564
3852
|
type: { type: "string", description: "Record type: A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, or TLSA" },
|
|
3565
3853
|
name: { type: "string", description: "Record name (e.g. @ or subdomain)" },
|
|
3566
3854
|
value: { type: "string", description: "Record value (e.g. IP address, hostname)" },
|
|
3567
|
-
ttl: { type: "number", description: "TTL in seconds (min 60, default 3600)" }
|
|
3855
|
+
ttl: { type: "number", description: "TTL in seconds (min 60, default 3600)" },
|
|
3856
|
+
dryRun: { type: "boolean", description: "Preview the change (returns proposed diff) without applying." }
|
|
3568
3857
|
},
|
|
3569
3858
|
required: ["domain", "type", "name", "value"]
|
|
3570
3859
|
}
|
|
3571
3860
|
},
|
|
3572
3861
|
{
|
|
3573
3862
|
name: "dns-update",
|
|
3574
|
-
description: "Update an existing DNS record. Identifies the record by type+name+oldValue, then replaces it with new values via PATCH.",
|
|
3863
|
+
description: "Update an existing DNS record. Identifies the record by type+name+oldValue, then replaces it with new values via PATCH.\n\nPass `dryRun: true` to preview the change without applying \u2014 strongly recommended for MX/SPF/DKIM/DMARC.",
|
|
3575
3864
|
inputSchema: {
|
|
3576
3865
|
type: "object",
|
|
3577
3866
|
properties: {
|
|
@@ -3580,21 +3869,23 @@ var TOOLS = [
|
|
|
3580
3869
|
name: { type: "string", description: "Record name" },
|
|
3581
3870
|
oldValue: { type: "string", description: "Current value of the record to update" },
|
|
3582
3871
|
newValue: { type: "string", description: "New value for the record" },
|
|
3583
|
-
ttl: { type: "number", description: "New TTL in seconds (min 60)" }
|
|
3872
|
+
ttl: { type: "number", description: "New TTL in seconds (min 60)" },
|
|
3873
|
+
dryRun: { type: "boolean", description: "Preview the change (returns proposed diff) without applying." }
|
|
3584
3874
|
},
|
|
3585
3875
|
required: ["domain", "type", "name", "oldValue", "newValue"]
|
|
3586
3876
|
}
|
|
3587
3877
|
},
|
|
3588
3878
|
{
|
|
3589
3879
|
name: "dns-delete",
|
|
3590
|
-
description: "Delete a DNS record by type, name, and value. Fetches all records, removes the matching one, then replaces the full set.",
|
|
3880
|
+
description: "Delete a DNS record by type, name, and value. Fetches all records, removes the matching one, then replaces the full set.\n\nPass `dryRun: true` to preview the deletion without applying.",
|
|
3591
3881
|
inputSchema: {
|
|
3592
3882
|
type: "object",
|
|
3593
3883
|
properties: {
|
|
3594
3884
|
domain: { type: "string", description: "Domain name (e.g. example.com)" },
|
|
3595
3885
|
type: { type: "string", description: "Record type to delete" },
|
|
3596
3886
|
name: { type: "string", description: "Record name to delete" },
|
|
3597
|
-
value: { type: "string", description: "Record value to delete (must match exactly)" }
|
|
3887
|
+
value: { type: "string", description: "Record value to delete (must match exactly)" },
|
|
3888
|
+
dryRun: { type: "boolean", description: "Preview the deletion (returns proposed diff) without applying." }
|
|
3598
3889
|
},
|
|
3599
3890
|
required: ["domain", "type", "name", "value"]
|
|
3600
3891
|
}
|
|
@@ -3604,7 +3895,7 @@ var TOOLS = [
|
|
|
3604
3895
|
// ----- Vercel -----
|
|
3605
3896
|
...VERCEL_TOOLS
|
|
3606
3897
|
];
|
|
3607
|
-
var MCP_VERSION = "
|
|
3898
|
+
var MCP_VERSION = "5.0.1";
|
|
3608
3899
|
async function handleListTools() {
|
|
3609
3900
|
if (!authContext) return { tools: TOOLS };
|
|
3610
3901
|
const accessible = TOOLS.filter((tool) => {
|
|
@@ -4568,29 +4859,46 @@ ${trail.join("\n")}` }] };
|
|
|
4568
4859
|
const output = await execSiteMysql(conn, String(a.sitePath), sql, proxy);
|
|
4569
4860
|
return { content: [{ type: "text", text: output || "No tables found" }] };
|
|
4570
4861
|
}
|
|
4571
|
-
case "db-describe": {
|
|
4572
|
-
const table = String(a.table).replace(/[^a-zA-Z0-9_]/g, "");
|
|
4573
|
-
const { conn, proxy } = await getServerConnection(String(a.serverId));
|
|
4574
|
-
const output = await execSiteMysql(
|
|
4575
|
-
conn,
|
|
4576
|
-
String(a.sitePath),
|
|
4577
|
-
`DESCRIBE \`${table}\`; SHOW INDEX FROM \`${table}\``,
|
|
4578
|
-
proxy
|
|
4579
|
-
);
|
|
4580
|
-
return { content: [{ type: "text", text: output }] };
|
|
4581
|
-
}
|
|
4582
4862
|
case "db-query": {
|
|
4583
|
-
const query = String(a.query).trim();
|
|
4584
|
-
if (!query) return { content: [{ type: "text", text: "Error: query is required" }] };
|
|
4585
|
-
assertSafeSql(query);
|
|
4586
4863
|
const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName.replace(/[^a-zA-Z0-9._-]/g, "") : "";
|
|
4587
4864
|
const explicitEngine = a.engine === "mysql" || a.engine === "postgres" || a.engine === "mssql" ? a.engine : null;
|
|
4588
4865
|
const engine = explicitEngine || (containerName ? "postgres" : "mysql");
|
|
4866
|
+
const describeArg = typeof a.describe === "string" ? a.describe.trim() : "";
|
|
4867
|
+
const explainMode = a.explain === true;
|
|
4868
|
+
const maxRowsRaw = a.maxRows === void 0 ? 1e3 : Number(a.maxRows);
|
|
4869
|
+
const maxRows = Math.min(Math.max(Number.isFinite(maxRowsRaw) ? maxRowsRaw : 0, 0), 1e4);
|
|
4870
|
+
let rawQuery;
|
|
4871
|
+
if (describeArg) {
|
|
4872
|
+
rawQuery = buildDescribeSql(describeArg, engine);
|
|
4873
|
+
} else {
|
|
4874
|
+
rawQuery = String(a.query ?? "").trim();
|
|
4875
|
+
if (!rawQuery) return { content: [{ type: "text", text: "Error: query (or describe) is required" }] };
|
|
4876
|
+
}
|
|
4877
|
+
assertSafeSql(rawQuery);
|
|
4878
|
+
let query = rawQuery.replace(/;\s*$/, "");
|
|
4879
|
+
let appliedLimit = false;
|
|
4880
|
+
if (explainMode && !describeArg) {
|
|
4881
|
+
if (engine === "mssql") {
|
|
4882
|
+
query = `SET SHOWPLAN_TEXT ON;
|
|
4883
|
+
${query}`;
|
|
4884
|
+
} else {
|
|
4885
|
+
query = `EXPLAIN ${query}`;
|
|
4886
|
+
}
|
|
4887
|
+
} else if (maxRows > 0 && !describeArg) {
|
|
4888
|
+
const isSelect = /^\s*(with\b[\s\S]+?\bselect\b|select\b)/i.test(query);
|
|
4889
|
+
const alreadyLimited = /\blimit\s+\d+\b/i.test(query) || /\btop\s*\(?\s*\d+/i.test(query);
|
|
4890
|
+
if (isSelect && !alreadyLimited && engine !== "mssql") {
|
|
4891
|
+
query = `${query}
|
|
4892
|
+
LIMIT ${maxRows + 1}`;
|
|
4893
|
+
appliedLimit = true;
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4589
4896
|
if (!containerName && engine === "mysql") {
|
|
4590
4897
|
if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
|
|
4591
4898
|
const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
|
|
4592
4899
|
const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
|
|
4593
|
-
|
|
4900
|
+
const footer2 = formatDbQueryFooter(output2, appliedLimit, maxRows, explainMode);
|
|
4901
|
+
return { content: [{ type: "text", text: (output2 || "Query executed successfully (no output)") + footer2 }] };
|
|
4594
4902
|
}
|
|
4595
4903
|
if (!containerName) {
|
|
4596
4904
|
return { content: [{ type: "text", text: `Error: engine=${engine} requires containerName (the docker container running the DB server, e.g. "supabase-db" or "trigger-postgres")` }] };
|
|
@@ -4629,7 +4937,8 @@ GO
|
|
|
4629
4937
|
if (result.exitCode !== 0 && !result.stdout) {
|
|
4630
4938
|
return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
|
|
4631
4939
|
}
|
|
4632
|
-
|
|
4940
|
+
const footer = formatDbQueryFooter(output, appliedLimit, maxRows, explainMode);
|
|
4941
|
+
return { content: [{ type: "text", text: output + footer }] };
|
|
4633
4942
|
}
|
|
4634
4943
|
// ----- Env Config -----
|
|
4635
4944
|
case "env-list": {
|
|
@@ -4818,13 +5127,68 @@ echo -e "$R"
|
|
|
4818
5127
|
if (!domains.length) {
|
|
4819
5128
|
return { content: [{ type: "text", text: "No domains found" }] };
|
|
4820
5129
|
}
|
|
5130
|
+
const details = a.details === true;
|
|
5131
|
+
if (!details) {
|
|
5132
|
+
const lines2 = domains.map((d) => {
|
|
5133
|
+
const tags = d.tags?.length ? ` [${d.tags.join(", ")}]` : "";
|
|
5134
|
+
return `${d.domain} status=${d.status} renewal=${d.renewal_date}${tags}`;
|
|
5135
|
+
});
|
|
5136
|
+
return { content: [{ type: "text", text: `${domains.length} domain(s):
|
|
5137
|
+
|
|
5138
|
+
${lines2.join("\n")}` }] };
|
|
5139
|
+
}
|
|
5140
|
+
const concurrency = Math.min(Math.max(Number(a.concurrency) || 8, 1), 20);
|
|
5141
|
+
const summaries = /* @__PURE__ */ new Map();
|
|
5142
|
+
const skipStatuses = /* @__PURE__ */ new Set(["expired", "redemptionperiod", "pendingdelete", "inactive"]);
|
|
5143
|
+
const activeDomains = domains.filter((d) => !skipStatuses.has((d.status || "").toLowerCase()));
|
|
5144
|
+
async function fetchSummary(domain) {
|
|
5145
|
+
try {
|
|
5146
|
+
const r = await mijnhostFetch(
|
|
5147
|
+
`/domains/${encodeURIComponent(domain)}/dns`
|
|
5148
|
+
);
|
|
5149
|
+
const recs = r.data.records || [];
|
|
5150
|
+
const ns = recs.filter((x) => x.type === "NS").map((x) => x.value).sort();
|
|
5151
|
+
const mx = recs.filter((x) => x.type === "MX").map((x) => x.value).sort();
|
|
5152
|
+
const hasSpf = recs.some((x) => x.type === "TXT" && x.value.toLowerCase().includes("v=spf1"));
|
|
5153
|
+
const hasDmarc = recs.some(
|
|
5154
|
+
(x) => x.type === "TXT" && (x.name.toLowerCase().startsWith("_dmarc") || x.value.toLowerCase().includes("v=dmarc1"))
|
|
5155
|
+
);
|
|
5156
|
+
return { ns, mx, hasSpf, hasDmarc };
|
|
5157
|
+
} catch (err) {
|
|
5158
|
+
return { ns: [], mx: [], hasSpf: false, hasDmarc: false, error: err instanceof Error ? err.message : String(err) };
|
|
5159
|
+
}
|
|
5160
|
+
}
|
|
5161
|
+
const queue = [...activeDomains];
|
|
5162
|
+
async function worker() {
|
|
5163
|
+
while (queue.length > 0) {
|
|
5164
|
+
const d = queue.shift();
|
|
5165
|
+
if (!d) return;
|
|
5166
|
+
summaries.set(d.domain, await fetchSummary(d.domain));
|
|
5167
|
+
}
|
|
5168
|
+
}
|
|
5169
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
|
4821
5170
|
const lines = domains.map((d) => {
|
|
4822
5171
|
const tags = d.tags?.length ? ` [${d.tags.join(", ")}]` : "";
|
|
4823
|
-
|
|
5172
|
+
const head = `${d.domain} status=${d.status} expires=${d.renewal_date}${tags}`;
|
|
5173
|
+
const s = summaries.get(d.domain);
|
|
5174
|
+
if (!s) return head;
|
|
5175
|
+
if (s.error) return `${head}
|
|
5176
|
+
dns: error: ${s.error}`;
|
|
5177
|
+
const ns = s.ns.length ? s.ns.join(", ") : "(none)";
|
|
5178
|
+
const mx = s.mx.length ? s.mx.join(", ") : "(none)";
|
|
5179
|
+
const mail = `mx=${mx} spf=${s.hasSpf ? "yes" : "NO"} dmarc=${s.hasDmarc ? "yes" : "NO"}`;
|
|
5180
|
+
return `${head}
|
|
5181
|
+
ns: ${ns}
|
|
5182
|
+
${mail}`;
|
|
4824
5183
|
});
|
|
4825
|
-
return {
|
|
5184
|
+
return {
|
|
5185
|
+
content: [{
|
|
5186
|
+
type: "text",
|
|
5187
|
+
text: `${domains.length} domain(s) (${activeDomains.length} with DNS lookup):
|
|
4826
5188
|
|
|
4827
|
-
${lines.join("\n")}`
|
|
5189
|
+
${lines.join("\n")}`
|
|
5190
|
+
}]
|
|
5191
|
+
};
|
|
4828
5192
|
}
|
|
4829
5193
|
case "dns-list": {
|
|
4830
5194
|
const domain = String(a.domain);
|
|
@@ -4853,13 +5217,22 @@ ${lines.join("\n")}` }] };
|
|
|
4853
5217
|
const dnsName = String(a.name);
|
|
4854
5218
|
const value = String(a.value);
|
|
4855
5219
|
const ttl = Number(a.ttl) || 3600;
|
|
5220
|
+
const dryRun = a.dryRun === true;
|
|
4856
5221
|
if (!domain || !type || !dnsName || !value) {
|
|
4857
5222
|
throw new Error("domain, type, name, and value are required");
|
|
4858
5223
|
}
|
|
4859
5224
|
const current = await mijnhostFetch(
|
|
4860
5225
|
`/domains/${encodeURIComponent(domain)}/dns`
|
|
4861
5226
|
);
|
|
4862
|
-
const
|
|
5227
|
+
const newRecord = { type, name: dnsName, value, ttl };
|
|
5228
|
+
const records = [...current.data.records, newRecord];
|
|
5229
|
+
if (dryRun) {
|
|
5230
|
+
const diff = formatDnsDiff(domain, current.data.records, records, {
|
|
5231
|
+
verb: "create",
|
|
5232
|
+
added: [newRecord]
|
|
5233
|
+
});
|
|
5234
|
+
return { content: [{ type: "text", text: diff }] };
|
|
5235
|
+
}
|
|
4863
5236
|
await mijnhostFetch(`/domains/${encodeURIComponent(domain)}/dns`, {
|
|
4864
5237
|
method: "PUT",
|
|
4865
5238
|
body: JSON.stringify({ records })
|
|
@@ -4873,6 +5246,7 @@ ${lines.join("\n")}` }] };
|
|
|
4873
5246
|
const oldValue = String(a.oldValue);
|
|
4874
5247
|
const newValue = String(a.newValue);
|
|
4875
5248
|
const ttl = Number(a.ttl) || void 0;
|
|
5249
|
+
const dryRun = a.dryRun === true;
|
|
4876
5250
|
if (!domain || !type || !dnsName || !oldValue || !newValue) {
|
|
4877
5251
|
throw new Error("domain, type, name, oldValue, and newValue are required");
|
|
4878
5252
|
}
|
|
@@ -4887,12 +5261,22 @@ ${lines.join("\n")}` }] };
|
|
|
4887
5261
|
}
|
|
4888
5262
|
const updated = [...current.data.records];
|
|
4889
5263
|
const existingTtl = updated[idx].ttl;
|
|
4890
|
-
updated[idx]
|
|
5264
|
+
const before = updated[idx];
|
|
5265
|
+
const after = {
|
|
4891
5266
|
type,
|
|
4892
5267
|
name: dnsName,
|
|
4893
5268
|
value: newValue,
|
|
4894
5269
|
ttl: ttl ?? existingTtl
|
|
4895
5270
|
};
|
|
5271
|
+
updated[idx] = after;
|
|
5272
|
+
if (dryRun) {
|
|
5273
|
+
const diff = formatDnsDiff(domain, current.data.records, updated, {
|
|
5274
|
+
verb: "update",
|
|
5275
|
+
removed: [before],
|
|
5276
|
+
added: [after]
|
|
5277
|
+
});
|
|
5278
|
+
return { content: [{ type: "text", text: diff }] };
|
|
5279
|
+
}
|
|
4896
5280
|
await mijnhostFetch(`/domains/${encodeURIComponent(domain)}/dns`, {
|
|
4897
5281
|
method: "PUT",
|
|
4898
5282
|
body: JSON.stringify({ records: updated })
|
|
@@ -4904,6 +5288,7 @@ ${lines.join("\n")}` }] };
|
|
|
4904
5288
|
const type = String(a.type).toUpperCase();
|
|
4905
5289
|
const dnsName = String(a.name);
|
|
4906
5290
|
const value = String(a.value);
|
|
5291
|
+
const dryRun = a.dryRun === true;
|
|
4907
5292
|
if (!domain || !type || !dnsName || !value) {
|
|
4908
5293
|
throw new Error("domain, type, name, and value are required");
|
|
4909
5294
|
}
|
|
@@ -4911,12 +5296,22 @@ ${lines.join("\n")}` }] };
|
|
|
4911
5296
|
`/domains/${encodeURIComponent(domain)}/dns`
|
|
4912
5297
|
);
|
|
4913
5298
|
const before = current.data.records.length;
|
|
5299
|
+
const removed = current.data.records.filter(
|
|
5300
|
+
(r) => r.type === type && r.name === dnsName && r.value === value
|
|
5301
|
+
);
|
|
4914
5302
|
const remaining = current.data.records.filter(
|
|
4915
5303
|
(r) => !(r.type === type && r.name === dnsName && r.value === value)
|
|
4916
5304
|
);
|
|
4917
5305
|
if (remaining.length === before) {
|
|
4918
5306
|
throw new Error(`No matching DNS record found: ${type} ${dnsName} = ${value}`);
|
|
4919
5307
|
}
|
|
5308
|
+
if (dryRun) {
|
|
5309
|
+
const diff = formatDnsDiff(domain, current.data.records, remaining, {
|
|
5310
|
+
verb: "delete",
|
|
5311
|
+
removed
|
|
5312
|
+
});
|
|
5313
|
+
return { content: [{ type: "text", text: diff }] };
|
|
5314
|
+
}
|
|
4920
5315
|
await mijnhostFetch(`/domains/${encodeURIComponent(domain)}/dns`, {
|
|
4921
5316
|
method: "PUT",
|
|
4922
5317
|
body: JSON.stringify({ records: remaining })
|