@mgsoftwarebv/mg-dashboard-mcp 3.14.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 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
- if (args2.status) queryParts.push(`filter%5Bstatus%5D=${String(args2.status)}`);
493
- if (args2.taskIdentifier) queryParts.push(`filter%5BtaskIdentifier%5D=${String(args2.taskIdentifier)}`);
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,
@@ -638,582 +653,6 @@ ${rawJson.substring(0, 500)}`
638
653
  }
639
654
  }
640
655
 
641
- // src/agent-tools.ts
642
- function clamp(val, min, max) {
643
- return Math.max(min, Math.min(max, val));
644
- }
645
- function sanitizeString(val, maxLen) {
646
- return String(val ?? "").slice(0, maxLen);
647
- }
648
- var AGENT_TOOLS = [
649
- {
650
- name: "web-search",
651
- description: "Search the web using DuckDuckGo. Returns a list of results with title, URL, and snippet. Use this to find companies, websites, directories, etc.",
652
- inputSchema: {
653
- type: "object",
654
- properties: {
655
- query: { type: "string", description: 'Search query (e.g. "logistiek bedrijf MKB nederland")' },
656
- max_results: { type: "number", description: "Max results to return (default: 15, max: 30)" }
657
- },
658
- required: ["query"]
659
- }
660
- },
661
- {
662
- name: "web-fetch",
663
- description: "Fetch a web page and return its text content (HTML tags stripped). Automatically extracts emails and phone numbers found on the page. Returns at most 15000 characters of cleaned text.",
664
- inputSchema: {
665
- type: "object",
666
- properties: {
667
- url: { type: "string", description: 'Full URL to fetch (e.g. "https://example.nl/contact")' },
668
- extract_links: { type: "boolean", description: "Also extract all links from the page (default: false)" }
669
- },
670
- required: ["url"]
671
- }
672
- },
673
- {
674
- name: "web-find-contacts",
675
- description: "POWERFUL contact finder. Crawls a company website (homepage + contact/about/team pages), extracts ALL emails, phone numbers, and LinkedIn URLs via regex + mailto/tel parsing. Also searches Google for the company email as fallback. Returns structured contact data. USE THIS for every lead instead of manually browsing contact pages.",
676
- inputSchema: {
677
- type: "object",
678
- properties: {
679
- url: {
680
- type: "string",
681
- description: 'Company website URL or domain (e.g. "https://example.nl" or "example.nl")'
682
- },
683
- company_name: {
684
- type: "string",
685
- description: "Company name (improves Google search accuracy)"
686
- },
687
- include_search: {
688
- type: "boolean",
689
- description: "Also search Google for emails (default: true)"
690
- }
691
- },
692
- required: ["url"]
693
- }
694
- }
695
- ];
696
- var AGENT_TOOL_NAMES = new Set(AGENT_TOOLS.map((t) => t.name));
697
- var AGENT_TOOL_MODULE_MAP = {
698
- "web-search": "agent_reporting",
699
- "web-fetch": "agent_reporting",
700
- "web-find-contacts": "agent_reporting"
701
- };
702
- var WEB_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
703
- async function webSearch(query, maxResults) {
704
- const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
705
- const response = await fetch(url, {
706
- headers: {
707
- "User-Agent": WEB_USER_AGENT,
708
- "Accept": "text/html",
709
- "Accept-Language": "nl,en;q=0.9"
710
- },
711
- redirect: "follow"
712
- });
713
- if (!response.ok) throw new Error(`Search failed: HTTP ${response.status}`);
714
- const html = await response.text();
715
- const results = [];
716
- const resultBlocks = html.split(/class="result\s/);
717
- for (let i = 1; i < resultBlocks.length && results.length < maxResults; i++) {
718
- const block = resultBlocks[i];
719
- const titleMatch = block.match(/class="result__a"[^>]*>([^<]+)</);
720
- const hrefMatch = block.match(/class="result__a"\s+href="([^"]+)"/);
721
- const snippetMatch = block.match(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
722
- if (!hrefMatch) continue;
723
- let href = hrefMatch[1];
724
- if (href.includes("uddg=")) {
725
- const uddg = new URL(href, "https://duckduckgo.com").searchParams.get("uddg");
726
- if (uddg) href = uddg;
727
- }
728
- if (!href.startsWith("http")) continue;
729
- const title = titleMatch ? titleMatch[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#x27;/g, "'").replace(/&quot;/g, '"').trim() : href;
730
- const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#x27;/g, "'").replace(/&quot;/g, '"').replace(/\s+/g, " ").trim() : "";
731
- results.push({ title, url: href, snippet });
732
- }
733
- return results;
734
- }
735
- var EMAIL_REGEX = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g;
736
- var PHONE_NL_REGEX = /(?:\+31|0)[\s\-.]?\(?\d{1,3}\)?[\s\-.]?\d{3,4}[\s\-.]?\d{2,4}/g;
737
- var LINKEDIN_REGEX = /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/[a-zA-Z0-9\-_%]+\/?/gi;
738
- var NOISE_EMAIL_PATTERNS = [
739
- /noreply@/i,
740
- /no-reply@/i,
741
- /mailer-daemon@/i,
742
- /postmaster@/i,
743
- /@example\./i,
744
- /test@/i,
745
- /@wix\.com$/i,
746
- /@sentry\.io$/i,
747
- /@wordpress\./i,
748
- /@gravatar\.com$/i,
749
- /@schema\.org$/i,
750
- /@w3\.org$/i,
751
- /@facebook\.com$/i,
752
- /@google\.com$/i,
753
- /@twitter\.com$/i,
754
- /@github\.com$/i,
755
- /@cloudflare\./i,
756
- /@vercel\./i,
757
- /@netlify\./i,
758
- /@cookiebot\./i,
759
- /@hotjar\./i,
760
- /@hubspot\./i,
761
- /@mailchimp\./i,
762
- /@googleusercontent/i,
763
- /@gstatic/i,
764
- /@youtube/i,
765
- /@recaptcha/i,
766
- /@privacy/i,
767
- /@cookie/i,
768
- /@gdpr/i,
769
- /@dynamicweb\./i,
770
- /@placeholder\./i,
771
- /@yourcompany\./i,
772
- /@company\./i,
773
- /smith@/i,
774
- /doe@/i,
775
- /demo@/i,
776
- /sample@/i,
777
- /naam@/i,
778
- /voorbeeld/i,
779
- /your-?email/i,
780
- /email@/i,
781
- /@domein\./i,
782
- /@bedrijf\./i,
783
- /@domain\./i,
784
- /@sentry/i,
785
- /@wixpress/i,
786
- /@lieferkassen/i,
787
- /john@/i,
788
- /jane@/i
789
- ];
790
- var CONTACT_PATH_KEYWORDS = [
791
- "contact",
792
- "about",
793
- "over-ons",
794
- "over",
795
- "team",
796
- "medewerker",
797
- "impressum",
798
- "imprint",
799
- "wie-zijn",
800
- "ons-team",
801
- "werknemers",
802
- "organisatie",
803
- "zakelijk",
804
- "bedrijfsinfo",
805
- "neem-contact",
806
- "footer"
807
- ];
808
- var CONTACT_PATHS_TO_TRY = [
809
- "/contact",
810
- "/over-ons",
811
- "/about",
812
- "/about-us",
813
- "/team",
814
- "/contact-us",
815
- "/over",
816
- "/wie-zijn-wij",
817
- "/ons-team",
818
- "/zakelijk/over-ons",
819
- "/medewerkers",
820
- "/organisatie",
821
- "/bedrijfsinformatie",
822
- "/impressum"
823
- ];
824
- function decodeUnicodeEscapes(text) {
825
- return text.replace(
826
- /\\u([0-9a-fA-F]{4})/g,
827
- (_, hex) => String.fromCharCode(parseInt(hex, 16))
828
- );
829
- }
830
- function decodeHtmlEntities(text) {
831
- return text.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num))).replace(
832
- /&#x([0-9a-fA-F]+);/g,
833
- (_, hex) => String.fromCharCode(parseInt(hex, 16))
834
- );
835
- }
836
- function extractEmailsFromHtml(html) {
837
- const emails = /* @__PURE__ */ new Set();
838
- const decoded = decodeHtmlEntities(decodeUnicodeEscapes(html));
839
- const mailtoRegex = /mailto:([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})/gi;
840
- let m;
841
- for (const src of [html, decoded]) {
842
- while ((m = mailtoRegex.exec(src)) !== null) emails.add(m[1].toLowerCase());
843
- }
844
- const noStyles = decoded.replace(/<style[\s\S]*?<\/style>/gi, "");
845
- const textEmails = noStyles.match(EMAIL_REGEX) || [];
846
- for (const e of textEmails) {
847
- const lower = e.toLowerCase();
848
- if (!/\.(png|jpg|jpeg|gif|svg|webp|css|js|woff|ttf|eot|ico)$/i.test(lower)) {
849
- emails.add(lower);
850
- }
851
- }
852
- return [...emails].filter((e) => !NOISE_EMAIL_PATTERNS.some((p) => p.test(e)));
853
- }
854
- function extractPhonesFromHtml(html) {
855
- const phones = /* @__PURE__ */ new Set();
856
- const decoded = decodeHtmlEntities(decodeUnicodeEscapes(html));
857
- const telRegex = /href="tel:([^"]+)"/gi;
858
- let m;
859
- for (const src of [html, decoded]) {
860
- while ((m = telRegex.exec(src)) !== null) {
861
- const clean = m[1].replace(/[\s\-().]/g, "");
862
- if (clean.length >= 10 && clean.length <= 14) phones.add(m[1].trim());
863
- }
864
- }
865
- const stripped = decoded.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ");
866
- const textPhones = stripped.match(PHONE_NL_REGEX) || [];
867
- for (const p of textPhones) {
868
- const clean = p.replace(/[\s\-().]/g, "");
869
- if (clean.length >= 10 && clean.length <= 14) phones.add(p.trim());
870
- }
871
- return [...phones].slice(0, 10);
872
- }
873
- function extractLinkedInFromHtml(html) {
874
- const links = /* @__PURE__ */ new Set();
875
- const matches = html.match(LINKEDIN_REGEX) || [];
876
- for (const l of matches) links.add(l.replace(/\/$/, ""));
877
- return [...links];
878
- }
879
- function discoverContactPages(html, baseUrl) {
880
- let base;
881
- try {
882
- base = new URL(baseUrl);
883
- } catch {
884
- return [];
885
- }
886
- const pages = /* @__PURE__ */ new Set();
887
- for (const path of CONTACT_PATHS_TO_TRY) {
888
- pages.add(`${base.origin}${path}`);
889
- }
890
- const linkRegex = /<a\s[^>]*href="([^"#]*)"[^>]*>/gi;
891
- let m;
892
- while ((m = linkRegex.exec(html)) !== null) {
893
- try {
894
- const url = new URL(m[1], baseUrl);
895
- if (url.hostname !== base.hostname) continue;
896
- const path = url.pathname.toLowerCase();
897
- if (CONTACT_PATH_KEYWORDS.some((kw) => path.includes(kw))) {
898
- pages.add(url.origin + url.pathname);
899
- }
900
- } catch {
901
- }
902
- }
903
- pages.delete(base.origin + base.pathname);
904
- return [...pages].slice(0, 10);
905
- }
906
- function guessCommonEmails(domain) {
907
- const d = domain.replace(/^www\./, "");
908
- return [`info@${d}`, `contact@${d}`, `hello@${d}`, `administratie@${d}`, `verkoop@${d}`];
909
- }
910
- var BOT_CHALLENGE_INDICATORS = [
911
- "sgcaptcha",
912
- "challenge-platform",
913
- "cf-browser-verification",
914
- "Just a moment",
915
- "Checking your browser",
916
- "Enable JavaScript and cookies",
917
- "Attention Required",
918
- "DDoS protection by"
919
- ];
920
- function isBotChallengePage(html) {
921
- if (html.length > 2e3) return false;
922
- const lower = html.toLowerCase();
923
- return BOT_CHALLENGE_INDICATORS.some((ind) => lower.includes(ind.toLowerCase()));
924
- }
925
- async function fetchRawHtml(url, timeoutMs = 1e4) {
926
- const controller = new AbortController();
927
- const timer = setTimeout(() => controller.abort(), timeoutMs);
928
- try {
929
- const res = await fetch(url, {
930
- headers: {
931
- "User-Agent": WEB_USER_AGENT,
932
- "Accept": "text/html,application/xhtml+xml",
933
- "Accept-Language": "nl,en;q=0.9"
934
- },
935
- redirect: "follow",
936
- signal: controller.signal
937
- });
938
- const ct = res.headers.get("content-type") || "";
939
- if (!ct.includes("text/html") && !ct.includes("text/plain") && !ct.includes("xhtml")) return null;
940
- if (!res.ok && res.status !== 403) return null;
941
- const html = await res.text();
942
- if (isBotChallengePage(html)) return null;
943
- return html;
944
- } catch {
945
- return null;
946
- } finally {
947
- clearTimeout(timer);
948
- }
949
- }
950
- async function fetchWaybackHtml(url, timeoutMs = 15e3) {
951
- const cleanUrl = url.replace(/^https?:\/\//, "");
952
- const wbUrl = `https://web.archive.org/web/2024/${cleanUrl}`;
953
- const controller = new AbortController();
954
- const timer = setTimeout(() => controller.abort(), timeoutMs);
955
- try {
956
- const res = await fetch(wbUrl, {
957
- headers: { "User-Agent": WEB_USER_AGENT },
958
- redirect: "follow",
959
- signal: controller.signal
960
- });
961
- if (!res.ok) return null;
962
- const html = await res.text();
963
- if (html.length < 500) return null;
964
- return html;
965
- } catch {
966
- return null;
967
- } finally {
968
- clearTimeout(timer);
969
- }
970
- }
971
- async function webFetch(url, extractLinks) {
972
- const controller = new AbortController();
973
- const timeout = setTimeout(() => controller.abort(), 15e3);
974
- try {
975
- const response = await fetch(url, {
976
- headers: {
977
- "User-Agent": WEB_USER_AGENT,
978
- "Accept": "text/html,application/xhtml+xml",
979
- "Accept-Language": "nl,en;q=0.9"
980
- },
981
- redirect: "follow",
982
- signal: controller.signal
983
- });
984
- if (!response.ok) throw new Error(`Fetch failed: HTTP ${response.status}`);
985
- const contentType = response.headers.get("content-type") || "";
986
- if (!contentType.includes("text/html") && !contentType.includes("text/plain") && !contentType.includes("application/xhtml")) {
987
- throw new Error(`Unsupported content type: ${contentType}. Only HTML/text pages supported.`);
988
- }
989
- const html = await response.text();
990
- const links = [];
991
- if (extractLinks) {
992
- const linkRegex = /<a\s[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
993
- let match;
994
- while ((match = linkRegex.exec(html)) !== null) {
995
- const href = match[1];
996
- const text2 = match[2].replace(/<[^>]+>/g, "").trim();
997
- if (href && href.startsWith("http")) {
998
- links.push({ href, text: text2.slice(0, 100) });
999
- }
1000
- }
1001
- }
1002
- let text = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<noscript[\s\S]*?<\/noscript>/gi, "").replace(/<nav[\s\S]*?<\/nav>/gi, " [NAV] ").replace(/<header[\s\S]*?<\/header>/gi, " [HEADER] ").replace(/<footer[\s\S]*?<\/footer>/gi, " [FOOTER] ").replace(/<(br|hr)\s*\/?>/gi, "\n").replace(/<\/(p|div|h[1-6]|li|tr|section|article)>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#x27;/g, "'").replace(/&quot;/g, '"').replace(/[ \t]+/g, " ").replace(/\n\s*\n/g, "\n").trim();
1003
- const MAX_TEXT = 15e3;
1004
- if (text.length > MAX_TEXT) {
1005
- text = text.slice(0, MAX_TEXT) + "\n\n[... truncated at 15000 chars ...]";
1006
- }
1007
- return { text, rawHtml: html, links };
1008
- } finally {
1009
- clearTimeout(timeout);
1010
- }
1011
- }
1012
- async function handleAgentTool(name, args2) {
1013
- switch (name) {
1014
- case "web-search": {
1015
- const query = sanitizeString(args2.query, 500);
1016
- if (!query) throw new Error("query is required");
1017
- const maxResults = clamp(Number(args2.max_results) || 15, 1, 30);
1018
- const results = await webSearch(query, maxResults);
1019
- if (results.length === 0) {
1020
- return { content: [{ type: "text", text: `No search results found for: "${query}"` }] };
1021
- }
1022
- const formatted = results.map(
1023
- (r, i) => `${i + 1}. ${r.title}
1024
- URL: ${r.url}
1025
- ${r.snippet}`
1026
- ).join("\n\n");
1027
- return {
1028
- content: [{
1029
- type: "text",
1030
- text: `Search results for "${query}" (${results.length} results):
1031
-
1032
- ${formatted}`
1033
- }]
1034
- };
1035
- }
1036
- case "web-fetch": {
1037
- const url = sanitizeString(args2.url, 2e3);
1038
- if (!url) throw new Error("url is required");
1039
- if (!/^https?:\/\//i.test(url)) throw new Error("url must start with http:// or https://");
1040
- const extractLinks = Boolean(args2.extract_links);
1041
- const result = await webFetch(url, extractLinks);
1042
- let text = `Content from ${url} (${result.text.length} chars):
1043
-
1044
- ${result.text}`;
1045
- if (extractLinks && result.links.length > 0) {
1046
- const linkList = result.links.slice(0, 50).map(
1047
- (l) => ` ${l.text ? l.text + ": " : ""}${l.href}`
1048
- ).join("\n");
1049
- text += `
1050
-
1051
- --- Links (${result.links.length} found, showing max 50) ---
1052
- ${linkList}`;
1053
- }
1054
- const pageEmails = extractEmailsFromHtml(result.rawHtml);
1055
- const pagePhones = extractPhonesFromHtml(result.rawHtml);
1056
- const pageLinkedIn = extractLinkedInFromHtml(result.rawHtml);
1057
- if (pageEmails.length > 0 || pagePhones.length > 0 || pageLinkedIn.length > 0) {
1058
- text += "\n\n--- Auto-extracted Contact Info ---";
1059
- if (pageEmails.length > 0) text += `
1060
- Emails: ${pageEmails.join(", ")}`;
1061
- if (pagePhones.length > 0) text += `
1062
- Phones: ${pagePhones.join(", ")}`;
1063
- if (pageLinkedIn.length > 0) text += `
1064
- LinkedIn: ${pageLinkedIn.join(", ")}`;
1065
- }
1066
- return { content: [{ type: "text", text }] };
1067
- }
1068
- case "web-find-contacts": {
1069
- const inputUrl = sanitizeString(args2.url, 2e3);
1070
- if (!inputUrl) throw new Error("url is required");
1071
- const companyName = sanitizeString(args2.company_name, 500);
1072
- const includeSearch = args2.include_search !== false;
1073
- const fullUrl = inputUrl.startsWith("http") ? inputUrl : `https://${inputUrl}`;
1074
- let baseUrl;
1075
- try {
1076
- baseUrl = new URL(fullUrl);
1077
- } catch {
1078
- throw new Error(`Invalid URL: ${inputUrl}`);
1079
- }
1080
- const domain = baseUrl.hostname.replace(/^www\./, "");
1081
- const urlsToTry = [fullUrl];
1082
- if (!fullUrl.includes("www.")) urlsToTry.push(fullUrl.replace("://", "://www."));
1083
- if (fullUrl.startsWith("https")) {
1084
- urlsToTry.push(fullUrl.replace("https", "http"));
1085
- if (!fullUrl.includes("www.")) urlsToTry.push(fullUrl.replace("https://", "http://www."));
1086
- }
1087
- let html = null;
1088
- let usedWayback = false;
1089
- for (const tryUrl of urlsToTry) {
1090
- html = await fetchRawHtml(tryUrl, 12e3);
1091
- if (html) break;
1092
- }
1093
- if (!html) {
1094
- html = await fetchWaybackHtml(`https://${domain}`, 15e3);
1095
- if (html) usedWayback = true;
1096
- }
1097
- if (!html) throw new Error(`Could not fetch ${fullUrl} (site may be down or blocking)`);
1098
- const contactPages = usedWayback ? [] : discoverContactPages(html, fullUrl);
1099
- const pagePromises = contactPages.map(async (pageUrl) => {
1100
- const pageHtml = await fetchRawHtml(pageUrl, 8e3);
1101
- return { url: pageUrl, html: pageHtml };
1102
- });
1103
- const pageResults = await Promise.allSettled(pagePromises);
1104
- const successPages = [usedWayback ? `(wayback) ${domain}` : fullUrl];
1105
- const allHtmls = [html];
1106
- for (const result of pageResults) {
1107
- if (result.status === "fulfilled" && result.value.html) {
1108
- allHtmls.push(result.value.html);
1109
- successPages.push(result.value.url);
1110
- }
1111
- }
1112
- if (usedWayback) {
1113
- const waybackContactPaths = ["/contact", "/over-ons", "/about", "/team"];
1114
- const wbPromises = waybackContactPaths.map(async (path) => {
1115
- const wbHtml = await fetchWaybackHtml(`https://${domain}${path}`, 12e3);
1116
- return { path, html: wbHtml };
1117
- });
1118
- const wbResults = await Promise.allSettled(wbPromises);
1119
- for (const wr of wbResults) {
1120
- if (wr.status === "fulfilled" && wr.value.html) {
1121
- allHtmls.push(wr.value.html);
1122
- successPages.push(`(wayback) ${domain}${wr.value.path}`);
1123
- }
1124
- }
1125
- }
1126
- const allEmails = /* @__PURE__ */ new Set();
1127
- const allPhones = /* @__PURE__ */ new Set();
1128
- const allLinkedIn = /* @__PURE__ */ new Set();
1129
- for (const pageHtml of allHtmls) {
1130
- for (const e of extractEmailsFromHtml(pageHtml)) allEmails.add(e);
1131
- for (const p of extractPhonesFromHtml(pageHtml)) allPhones.add(p);
1132
- for (const l of extractLinkedInFromHtml(pageHtml)) allLinkedIn.add(l);
1133
- }
1134
- let searchNote = "";
1135
- if (includeSearch) {
1136
- try {
1137
- const queries = companyName ? [`"${companyName}" "${domain}" email`, `"@${domain}" email`] : [`"${domain}" email contact`, `"@${domain}" email`, `site:${domain} "@"`];
1138
- let totalSearchResults = 0;
1139
- for (const searchQuery of queries) {
1140
- const searchResults = await webSearch(searchQuery, 8);
1141
- totalSearchResults += searchResults.length;
1142
- for (const r of searchResults) {
1143
- const combined = `${r.title} ${r.snippet}`;
1144
- const decoded = decodeHtmlEntities(decodeUnicodeEscapes(combined));
1145
- const snippetEmails = decoded.match(EMAIL_REGEX) || [];
1146
- for (const e of snippetEmails) {
1147
- const lower = e.toLowerCase();
1148
- const domainBase = domain.split(".")[0] ?? domain;
1149
- if (lower.includes(domainBase) && !NOISE_EMAIL_PATTERNS.some((p) => p.test(lower))) {
1150
- allEmails.add(lower);
1151
- }
1152
- }
1153
- const snippetPhones = decoded.match(PHONE_NL_REGEX) || [];
1154
- for (const p of snippetPhones) {
1155
- const clean = p.replace(/[\s\-().]/g, "");
1156
- if (clean.length >= 10 && clean.length <= 14) allPhones.add(p.trim());
1157
- }
1158
- const snippetLi = decoded.match(LINKEDIN_REGEX) || [];
1159
- for (const l of snippetLi) allLinkedIn.add(l.replace(/\/$/, ""));
1160
- }
1161
- if (allEmails.size > 0) break;
1162
- }
1163
- searchNote = ` + ${totalSearchResults} search results`;
1164
- } catch {
1165
- searchNote = " (search failed)";
1166
- }
1167
- }
1168
- const foundEmails = [...allEmails];
1169
- const generalPatterns = /^(info|contact|hello|admin|office|receptie|secretariaat|verkoop|administratie|support|service|boekingen|reserveringen)@/i;
1170
- const generalEmails = foundEmails.filter((e) => generalPatterns.test(e));
1171
- const personalEmails = foundEmails.filter((e) => !generalPatterns.test(e));
1172
- const guessed = guessCommonEmails(domain);
1173
- const newGuesses = guessed.filter((g) => !allEmails.has(g));
1174
- const lines = [
1175
- `=== CONTACT SCAN: ${domain} ===`,
1176
- `Pages scanned: ${successPages.length}${searchNote}`,
1177
- ""
1178
- ];
1179
- if (foundEmails.length > 0) {
1180
- lines.push(`EMAILS FOUND (${foundEmails.length}):`);
1181
- if (generalEmails.length > 0) {
1182
- lines.push(" General:");
1183
- for (const e of generalEmails) lines.push(` * ${e}`);
1184
- }
1185
- if (personalEmails.length > 0) {
1186
- lines.push(" Personal/Department:");
1187
- for (const e of personalEmails) lines.push(` * ${e}`);
1188
- }
1189
- } else {
1190
- lines.push("EMAILS FOUND: None on website");
1191
- lines.push("SUGGESTED EMAILS (common patterns, verify before use):");
1192
- for (const e of newGuesses) lines.push(` ? ${e}`);
1193
- }
1194
- lines.push("");
1195
- if ([...allPhones].length > 0) {
1196
- lines.push(`PHONE NUMBERS (${allPhones.size}):`);
1197
- for (const p of allPhones) lines.push(` ${p}`);
1198
- } else {
1199
- lines.push("PHONE NUMBERS: None found");
1200
- }
1201
- lines.push("");
1202
- if ([...allLinkedIn].length > 0) {
1203
- lines.push(`LINKEDIN (${allLinkedIn.size}):`);
1204
- for (const l of allLinkedIn) lines.push(` ${l}`);
1205
- }
1206
- lines.push("", "PAGES CHECKED:");
1207
- for (const p of successPages) lines.push(` [OK] ${p}`);
1208
- const failedPages = contactPages.filter((p) => !successPages.includes(p));
1209
- for (const p of failedPages) lines.push(` [--] ${p}`);
1210
- return { content: [{ type: "text", text: lines.join("\n") }] };
1211
- }
1212
- default:
1213
- return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
1214
- }
1215
- }
1216
-
1217
656
  // src/vercel-tools.ts
1218
657
  var VERCEL_API = "https://api.vercel.com";
1219
658
  var VERCEL_TOOLS = [
@@ -1245,6 +684,41 @@ var VERCEL_TOOLS = [
1245
684
  required: ["project"]
1246
685
  }
1247
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
+ },
1248
722
  {
1249
723
  name: "vercel-logs",
1250
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.',
@@ -1274,7 +748,7 @@ var VERCEL_TOOLS = [
1274
748
  },
1275
749
  sinceMinutes: {
1276
750
  type: "number",
1277
- description: 'Time window in minutes (kind="runtime" only, default 60, max 7 days).'
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.`
1278
752
  },
1279
753
  limit: {
1280
754
  type: "number",
@@ -1288,11 +762,14 @@ var VERCEL_TOOL_NAMES = new Set(VERCEL_TOOLS.map((t) => t.name));
1288
762
  var VERCEL_TOOL_MODULE_MAP = {
1289
763
  "vercel-projects": "ci_cd",
1290
764
  "vercel-deployments": "ci_cd",
1291
- "vercel-logs": "ci_cd"
765
+ "vercel-logs": "ci_cd",
766
+ "vercel-domains": "ci_cd"
1292
767
  };
1293
- async function vercelFetch(token, path) {
768
+ async function vercelFetch(token, path, init) {
1294
769
  const res = await fetch(`${VERCEL_API}${path}`, {
1295
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
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
1296
773
  });
1297
774
  const body = await res.text();
1298
775
  let parsed = null;
@@ -1376,6 +853,14 @@ async function getDeploymentBuildEvents(token, deploymentId, limit) {
1376
853
  }
1377
854
  return { events, error: null };
1378
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
+ }
1379
864
  async function getRuntimeLogs(token, projectIdOrName, deploymentId, limit, sinceMs) {
1380
865
  const params = new URLSearchParams({
1381
866
  limit: String(Math.min(Math.max(limit, 1), 1e3))
@@ -1416,6 +901,47 @@ async function getRuntimeLogs(token, projectIdOrName, deploymentId, limit, since
1416
901
  }
1417
902
  return { logs, error: null };
1418
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
+ }
1419
945
  async function getVercelToken(deps) {
1420
946
  const { data, error } = await deps.supabase.from("app_setting").select("vercel_token_encrypted").maybeSingle();
1421
947
  if (error) throw new Error(`Could not read app_setting: ${error.message}`);
@@ -1511,6 +1037,42 @@ function formatWebhookHistory(rows) {
1511
1037
  ${"-".repeat(header.length)}
1512
1038
  ${lines.join("\n")}`;
1513
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
+ }
1514
1076
  async function handleVercelTool(name, args2, deps) {
1515
1077
  switch (name) {
1516
1078
  case "vercel-projects": {
@@ -1561,9 +1123,33 @@ async function handleVercelTool(name, args2, deps) {
1561
1123
  const projectInput = String(args2.project);
1562
1124
  const deploymentId = String(args2.deploymentId);
1563
1125
  const limit = Math.min(Math.max(Number(args2.limit) || 200, 1), 1e3);
1564
- const sinceMinutes = Math.min(Math.max(Number(args2.sinceMinutes) || 60, 1), 7 * 24 * 60);
1565
- const sinceMs = Date.now() - sinceMinutes * 6e4;
1566
- const projectId = await resolveProjectId(token, projectInput);
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
+ }
1567
1153
  const { logs, error } = await getRuntimeLogs(
1568
1154
  token,
1569
1155
  projectId,
@@ -1575,7 +1161,10 @@ async function handleVercelTool(name, args2, deps) {
1575
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.' : "";
1576
1162
  return { content: [{ type: "text", text: `Error: ${error}${hint}` }] };
1577
1163
  }
1578
- return { content: [{ type: "text", text: formatRuntimeLogs(logs) }] };
1164
+ const text = `${formatRuntimeLogs(logs)}
1165
+
1166
+ [${windowNote}]`;
1167
+ return { content: [{ type: "text", text }] };
1579
1168
  }
1580
1169
  if (kind === "webhooks") {
1581
1170
  const limit = Math.min(Math.max(Number(args2.limit) || 25, 1), 200);
@@ -1596,6 +1185,67 @@ async function handleVercelTool(name, args2, deps) {
1596
1185
  ]
1597
1186
  };
1598
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
+ }
1599
1249
  default:
1600
1250
  return { content: [{ type: "text", text: `Unknown vercel tool: ${name}` }] };
1601
1251
  }
@@ -1711,8 +1361,7 @@ var MODULE_KEYS = [
1711
1361
  "wiki",
1712
1362
  "ci_cd",
1713
1363
  "domains",
1714
- "settings",
1715
- "agent_reporting"
1364
+ "settings"
1716
1365
  ];
1717
1366
  var FULL_PERMISSIONS = {
1718
1367
  modules: Object.fromEntries(MODULE_KEYS.map((k) => [k, true])),
@@ -1763,7 +1412,6 @@ var TOOL_MODULE_MAP = {
1763
1412
  "wait-for": "ssh_servers",
1764
1413
  "db-discover": "ssh_servers",
1765
1414
  "db-tables": "ssh_servers",
1766
- "db-describe": "ssh_servers",
1767
1415
  "db-query": "ssh_servers",
1768
1416
  "cache-purge": "ssh_servers",
1769
1417
  "env-list": "ci_cd",
@@ -1775,7 +1423,6 @@ var TOOL_MODULE_MAP = {
1775
1423
  "dns-update": "domains",
1776
1424
  "dns-delete": "domains",
1777
1425
  ...TRIGGER_TOOL_MODULE_MAP,
1778
- ...AGENT_TOOL_MODULE_MAP,
1779
1426
  ...VERCEL_TOOL_MODULE_MAP
1780
1427
  };
1781
1428
  var authContext = null;
@@ -3724,6 +3371,42 @@ var BLOCKED_SQL_PATTERNS = [
3724
3371
  /\bALTER\s+TABLE\s+\w+\s+DROP\b/i,
3725
3372
  /\bDELETE\s+FROM\s+\w+\s*$/i
3726
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
+ }
3727
3410
  function assertSafeSql(query) {
3728
3411
  const trimmed = query.trim();
3729
3412
  for (const pattern of BLOCKED_SQL_PATTERNS) {
@@ -3795,6 +3478,36 @@ function requireMijnhostApiKey() {
3795
3478
  }
3796
3479
  return mijnhostApiKey;
3797
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
+ }
3798
3511
  async function mijnhostFetch(path, options = {}) {
3799
3512
  const key = requireMijnhostApiKey();
3800
3513
  const res = await fetch(`${MIJNHOST_BASE_URL}${path}`, {
@@ -4035,35 +3748,25 @@ var TOOLS = [
4035
3748
  required: ["serverId", "sitePath"]
4036
3749
  }
4037
3750
  },
4038
- {
4039
- name: "db-describe",
4040
- description: "Show the structure of a database table (columns, types, keys, defaults). Credentials are auto-discovered from site config files.",
4041
- inputSchema: {
4042
- type: "object",
4043
- properties: {
4044
- serverId: { type: "string", description: "UUID of the SSH server" },
4045
- sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com)" },
4046
- table: { type: "string", description: "Table name" }
4047
- },
4048
- required: ["serverId", "sitePath", "table"]
4049
- }
4050
- },
4051
3751
  {
4052
3752
  name: "db-query",
4053
- 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.',
4054
3754
  inputSchema: {
4055
3755
  type: "object",
4056
3756
  properties: {
4057
3757
  serverId: { type: "string", description: "UUID of the SSH server" },
4058
- 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.' },
4059
3760
  sitePath: { type: "string", description: "Site root path (e.g. /var/www/example.com). Used only with engine=mysql autodiscover." },
4060
3761
  containerName: { type: "string", description: 'Docker container running the DB server (e.g. "supabase-db", "trigger-postgres"). Activates direct-query mode.' },
4061
3762
  engine: { type: "string", enum: ["mysql", "postgres", "mssql"], description: 'DB engine. Defaults to "mysql" with sitePath, "postgres" with containerName.' },
4062
3763
  dbName: { type: "string", description: 'Database name (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 server default, mssql \u2192 server default.' },
4063
3764
  dbUser: { type: "string", description: 'Database user (containerName mode). Defaults: postgres \u2192 "postgres", mysql \u2192 "root", mssql \u2192 "sa".' },
4064
- 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." }
4065
3768
  },
4066
- required: ["serverId", "query"]
3769
+ required: ["serverId"]
4067
3770
  }
4068
3771
  },
4069
3772
  {
@@ -4118,8 +3821,15 @@ var TOOLS = [
4118
3821
  // ----- Domains (mijn.host) -----
4119
3822
  {
4120
3823
  name: "domain-list",
4121
- description: "List all domains from the mijn.host account. Returns domain name, status, renewal date, and tags. Requires MIJNHOST_API_KEY.",
4122
- inputSchema: { type: "object", properties: {}, required: [] }
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
+ }
4123
3833
  },
4124
3834
  {
4125
3835
  name: "dns-list",
@@ -4134,7 +3844,7 @@ var TOOLS = [
4134
3844
  },
4135
3845
  {
4136
3846
  name: "dns-create",
4137
- 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.",
4138
3848
  inputSchema: {
4139
3849
  type: "object",
4140
3850
  properties: {
@@ -4142,14 +3852,15 @@ var TOOLS = [
4142
3852
  type: { type: "string", description: "Record type: A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, or TLSA" },
4143
3853
  name: { type: "string", description: "Record name (e.g. @ or subdomain)" },
4144
3854
  value: { type: "string", description: "Record value (e.g. IP address, hostname)" },
4145
- 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." }
4146
3857
  },
4147
3858
  required: ["domain", "type", "name", "value"]
4148
3859
  }
4149
3860
  },
4150
3861
  {
4151
3862
  name: "dns-update",
4152
- 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.",
4153
3864
  inputSchema: {
4154
3865
  type: "object",
4155
3866
  properties: {
@@ -4158,33 +3869,33 @@ var TOOLS = [
4158
3869
  name: { type: "string", description: "Record name" },
4159
3870
  oldValue: { type: "string", description: "Current value of the record to update" },
4160
3871
  newValue: { type: "string", description: "New value for the record" },
4161
- 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." }
4162
3874
  },
4163
3875
  required: ["domain", "type", "name", "oldValue", "newValue"]
4164
3876
  }
4165
3877
  },
4166
3878
  {
4167
3879
  name: "dns-delete",
4168
- 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.",
4169
3881
  inputSchema: {
4170
3882
  type: "object",
4171
3883
  properties: {
4172
3884
  domain: { type: "string", description: "Domain name (e.g. example.com)" },
4173
3885
  type: { type: "string", description: "Record type to delete" },
4174
3886
  name: { type: "string", description: "Record name to delete" },
4175
- 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." }
4176
3889
  },
4177
3890
  required: ["domain", "type", "name", "value"]
4178
3891
  }
4179
3892
  },
4180
3893
  // ----- Trigger.dev -----
4181
3894
  ...TRIGGER_TOOLS,
4182
- // ----- Agent Reporting -----
4183
- ...AGENT_TOOLS,
4184
3895
  // ----- Vercel -----
4185
3896
  ...VERCEL_TOOLS
4186
3897
  ];
4187
- var MCP_VERSION = "3.14.0";
3898
+ var MCP_VERSION = "5.0.1";
4188
3899
  async function handleListTools() {
4189
3900
  if (!authContext) return { tools: TOOLS };
4190
3901
  const accessible = TOOLS.filter((tool) => {
@@ -5148,29 +4859,46 @@ ${trail.join("\n")}` }] };
5148
4859
  const output = await execSiteMysql(conn, String(a.sitePath), sql, proxy);
5149
4860
  return { content: [{ type: "text", text: output || "No tables found" }] };
5150
4861
  }
5151
- case "db-describe": {
5152
- const table = String(a.table).replace(/[^a-zA-Z0-9_]/g, "");
5153
- const { conn, proxy } = await getServerConnection(String(a.serverId));
5154
- const output = await execSiteMysql(
5155
- conn,
5156
- String(a.sitePath),
5157
- `DESCRIBE \`${table}\`; SHOW INDEX FROM \`${table}\``,
5158
- proxy
5159
- );
5160
- return { content: [{ type: "text", text: output }] };
5161
- }
5162
4862
  case "db-query": {
5163
- const query = String(a.query).trim();
5164
- if (!query) return { content: [{ type: "text", text: "Error: query is required" }] };
5165
- assertSafeSql(query);
5166
4863
  const containerName = typeof a.containerName === "string" && a.containerName ? a.containerName.replace(/[^a-zA-Z0-9._-]/g, "") : "";
5167
4864
  const explicitEngine = a.engine === "mysql" || a.engine === "postgres" || a.engine === "mssql" ? a.engine : null;
5168
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
+ }
5169
4896
  if (!containerName && engine === "mysql") {
5170
4897
  if (!a.sitePath) return { content: [{ type: "text", text: "Error: sitePath is required for mysql autodiscover (or pass containerName + engine for direct DB queries)" }] };
5171
4898
  const { conn: conn2, proxy: proxy2 } = await getServerConnection(String(a.serverId));
5172
4899
  const output2 = await execSiteMysql(conn2, String(a.sitePath), query, proxy2);
5173
- return { content: [{ type: "text", text: output2 || "Query executed successfully (no output)" }] };
4900
+ const footer2 = formatDbQueryFooter(output2, appliedLimit, maxRows, explainMode);
4901
+ return { content: [{ type: "text", text: (output2 || "Query executed successfully (no output)") + footer2 }] };
5174
4902
  }
5175
4903
  if (!containerName) {
5176
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")` }] };
@@ -5209,7 +4937,8 @@ GO
5209
4937
  if (result.exitCode !== 0 && !result.stdout) {
5210
4938
  return { content: [{ type: "text", text: `Error (exit ${result.exitCode}, ${engine}): ${output}` }] };
5211
4939
  }
5212
- return { content: [{ type: "text", text: output }] };
4940
+ const footer = formatDbQueryFooter(output, appliedLimit, maxRows, explainMode);
4941
+ return { content: [{ type: "text", text: output + footer }] };
5213
4942
  }
5214
4943
  // ----- Env Config -----
5215
4944
  case "env-list": {
@@ -5398,13 +5127,68 @@ echo -e "$R"
5398
5127
  if (!domains.length) {
5399
5128
  return { content: [{ type: "text", text: "No domains found" }] };
5400
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));
5401
5170
  const lines = domains.map((d) => {
5402
5171
  const tags = d.tags?.length ? ` [${d.tags.join(", ")}]` : "";
5403
- return `${d.domain} status=${d.status} renewal=${d.renewal_date}${tags}`;
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}`;
5404
5183
  });
5405
- return { content: [{ type: "text", text: `${domains.length} domain(s):
5184
+ return {
5185
+ content: [{
5186
+ type: "text",
5187
+ text: `${domains.length} domain(s) (${activeDomains.length} with DNS lookup):
5406
5188
 
5407
- ${lines.join("\n")}` }] };
5189
+ ${lines.join("\n")}`
5190
+ }]
5191
+ };
5408
5192
  }
5409
5193
  case "dns-list": {
5410
5194
  const domain = String(a.domain);
@@ -5433,13 +5217,22 @@ ${lines.join("\n")}` }] };
5433
5217
  const dnsName = String(a.name);
5434
5218
  const value = String(a.value);
5435
5219
  const ttl = Number(a.ttl) || 3600;
5220
+ const dryRun = a.dryRun === true;
5436
5221
  if (!domain || !type || !dnsName || !value) {
5437
5222
  throw new Error("domain, type, name, and value are required");
5438
5223
  }
5439
5224
  const current = await mijnhostFetch(
5440
5225
  `/domains/${encodeURIComponent(domain)}/dns`
5441
5226
  );
5442
- const records = [...current.data.records, { type, name: dnsName, value, ttl }];
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
+ }
5443
5236
  await mijnhostFetch(`/domains/${encodeURIComponent(domain)}/dns`, {
5444
5237
  method: "PUT",
5445
5238
  body: JSON.stringify({ records })
@@ -5453,6 +5246,7 @@ ${lines.join("\n")}` }] };
5453
5246
  const oldValue = String(a.oldValue);
5454
5247
  const newValue = String(a.newValue);
5455
5248
  const ttl = Number(a.ttl) || void 0;
5249
+ const dryRun = a.dryRun === true;
5456
5250
  if (!domain || !type || !dnsName || !oldValue || !newValue) {
5457
5251
  throw new Error("domain, type, name, oldValue, and newValue are required");
5458
5252
  }
@@ -5467,12 +5261,22 @@ ${lines.join("\n")}` }] };
5467
5261
  }
5468
5262
  const updated = [...current.data.records];
5469
5263
  const existingTtl = updated[idx].ttl;
5470
- updated[idx] = {
5264
+ const before = updated[idx];
5265
+ const after = {
5471
5266
  type,
5472
5267
  name: dnsName,
5473
5268
  value: newValue,
5474
5269
  ttl: ttl ?? existingTtl
5475
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
+ }
5476
5280
  await mijnhostFetch(`/domains/${encodeURIComponent(domain)}/dns`, {
5477
5281
  method: "PUT",
5478
5282
  body: JSON.stringify({ records: updated })
@@ -5484,6 +5288,7 @@ ${lines.join("\n")}` }] };
5484
5288
  const type = String(a.type).toUpperCase();
5485
5289
  const dnsName = String(a.name);
5486
5290
  const value = String(a.value);
5291
+ const dryRun = a.dryRun === true;
5487
5292
  if (!domain || !type || !dnsName || !value) {
5488
5293
  throw new Error("domain, type, name, and value are required");
5489
5294
  }
@@ -5491,12 +5296,22 @@ ${lines.join("\n")}` }] };
5491
5296
  `/domains/${encodeURIComponent(domain)}/dns`
5492
5297
  );
5493
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
+ );
5494
5302
  const remaining = current.data.records.filter(
5495
5303
  (r) => !(r.type === type && r.name === dnsName && r.value === value)
5496
5304
  );
5497
5305
  if (remaining.length === before) {
5498
5306
  throw new Error(`No matching DNS record found: ${type} ${dnsName} = ${value}`);
5499
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
+ }
5500
5315
  await mijnhostFetch(`/domains/${encodeURIComponent(domain)}/dns`, {
5501
5316
  method: "PUT",
5502
5317
  body: JSON.stringify({ records: remaining })
@@ -5507,9 +5322,6 @@ ${lines.join("\n")}` }] };
5507
5322
  if (TRIGGER_TOOL_NAMES.has(name)) {
5508
5323
  return handleTriggerTool(name, a, { sshExec, getServerConnection });
5509
5324
  }
5510
- if (AGENT_TOOL_NAMES.has(name)) {
5511
- return handleAgentTool(name, a);
5512
- }
5513
5325
  if (VERCEL_TOOL_NAMES.has(name)) {
5514
5326
  return handleVercelTool(name, a, { supabase, decrypt });
5515
5327
  }
@@ -5547,50 +5359,8 @@ async function main() {
5547
5359
  if (httpMode) {
5548
5360
  console.error(`API key validated. Starting Streamable HTTP transport on port ${httpPort}...`);
5549
5361
  const transports = /* @__PURE__ */ new Map();
5550
- const REST_TOOL_MAP = {
5551
- "/api/web-search": "web-search",
5552
- "/api/web-fetch": "web-fetch"
5553
- };
5554
5362
  const httpServer = createServer(async (req, res) => {
5555
5363
  const url = new URL(req.url ?? "/", `http://localhost:${httpPort}`);
5556
- const restToolName = REST_TOOL_MAP[url.pathname];
5557
- if (restToolName && req.method === "POST") {
5558
- if (!authContext) {
5559
- res.writeHead(401, { "Content-Type": "application/json" });
5560
- res.end(JSON.stringify({ error: "Not authenticated" }));
5561
- return;
5562
- }
5563
- const requiredModule = TOOL_MODULE_MAP[restToolName];
5564
- if (requiredModule && authContext.permissions.modules[requiredModule] !== true) {
5565
- res.writeHead(403, { "Content-Type": "application/json" });
5566
- res.end(JSON.stringify({ error: `Access denied: no permission for "${requiredModule}" module` }));
5567
- return;
5568
- }
5569
- const chunks = [];
5570
- for await (const chunk of req) chunks.push(chunk);
5571
- let toolArgs;
5572
- try {
5573
- toolArgs = JSON.parse(Buffer.concat(chunks).toString());
5574
- } catch {
5575
- res.writeHead(400, { "Content-Type": "application/json" });
5576
- res.end(JSON.stringify({ error: "Invalid JSON" }));
5577
- return;
5578
- }
5579
- try {
5580
- const result = await handleAgentTool(restToolName, toolArgs);
5581
- res.writeHead(200, { "Content-Type": "application/json" });
5582
- res.end(JSON.stringify({ ok: true, result }));
5583
- } catch (err) {
5584
- res.writeHead(500, { "Content-Type": "application/json" });
5585
- res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
5586
- }
5587
- return;
5588
- }
5589
- if (url.pathname === "/api/tools" && req.method === "GET") {
5590
- res.writeHead(200, { "Content-Type": "application/json" });
5591
- res.end(JSON.stringify({ tools: Object.keys(REST_TOOL_MAP) }));
5592
- return;
5593
- }
5594
5364
  if (url.pathname !== "/mcp") {
5595
5365
  res.writeHead(404, { "Content-Type": "text/plain" });
5596
5366
  res.end("Not found");