@mgsoftwarebv/mg-dashboard-mcp 7.4.1 → 7.4.2

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
@@ -674,26 +674,47 @@ var TRIGGER_TOOLS = [
674
674
  },
675
675
  required: ["action", "project"]
676
676
  }
677
+ },
678
+ {
679
+ name: "trigger-env",
680
+ description: 'Manage a Trigger.dev project\'s environment variables via the official Management API (the webapp encrypts/decrypts \u2014 no direct SecretStore/DB writes, upgrade-safe). SUPERADMIN ONLY: this reads and writes production secrets across every project. Pick the operation with `action`:\n- "list": all variable names + a secret flag for the env (values are NOT returned \u2014 use "get").\n- "get": one variable. Secret values are masked unless includeSecret=true. Required: key.\n- "set": create or update a variable (upsert) + read-back verify. Required: key, value. Optional: isSecret (only sent when provided, so existing vars keep their flag).\n- "delete": remove a variable. Required: key.\nOn the self-hosted instance only the "prod" environment exists (env defaults to "prod").',
681
+ inputSchema: {
682
+ type: "object",
683
+ properties: {
684
+ action: { type: "string", enum: ["list", "get", "set", "delete"], description: "Which env-var operation to perform." },
685
+ project: { type: "string", description: 'Project slug from trigger-list (e.g. "domeinflits-vsJ2")' },
686
+ key: { type: "string", description: "Environment variable name. Required for get/set/delete." },
687
+ value: { type: "string", description: 'Value to store. Required for action="set".' },
688
+ env: { type: "string", description: 'Environment slug. Default "prod" (only prod exists on self-hosted).' },
689
+ isSecret: { type: "boolean", description: `action="set" only: mark the variable as secret. Omit to leave an existing var's flag unchanged.` },
690
+ includeSecret: { type: "boolean", description: 'action="get" only: reveal a secret value instead of masking it (default false).' }
691
+ },
692
+ required: ["action", "project"]
693
+ }
677
694
  }
678
695
  ];
679
696
  var TRIGGER_TOOL_NAMES = new Set(TRIGGER_TOOLS.map((t) => t.name));
680
697
  var TRIGGER_TOOL_MODULE_MAP = {
681
698
  "trigger-list": "ci_cd",
682
699
  "trigger-runs": "ci_cd",
683
- "trigger-run": "ci_cd"
700
+ "trigger-run": "ci_cd",
701
+ // trigger-env manages production secrets → gated behind the admin `settings`
702
+ // module AND an extra superadmin-only check in index.ts (defense in depth).
703
+ "trigger-env": "settings"
684
704
  };
685
705
  async function discoverInstance(projectSlug, conn, proxy, sshExec2) {
686
- const sql26 = `SELECT re.\\"apiKey\\" FROM \\"RuntimeEnvironment\\" re JOIN \\"Project\\" p ON re.\\"projectId\\" = p.id WHERE p.slug='${projectSlug}' AND re.slug='prod' LIMIT 1`;
706
+ const sql26 = `SELECT re.\\"apiKey\\" || '~~' || p.\\"externalRef\\" FROM \\"RuntimeEnvironment\\" re JOIN \\"Project\\" p ON re.\\"projectId\\" = p.id WHERE p.slug='${projectSlug}' AND re.slug='prod' LIMIT 1`;
687
707
  const cmd = [
688
708
  `PORT=$(docker port "${WA_CONTAINER}" 3000/tcp 2>/dev/null | head -1 | sed 's/.*://')`,
689
- `KEY=$(docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql26}" 2>/dev/null | tr -d '[:space:]')`,
690
- 'echo "$PORT|$KEY"'
709
+ `ROW=$(docker exec "${PG_CONTAINER}" psql -U postgres -d main -t -A -c "${sql26}" 2>/dev/null | tr -d '[:space:]')`,
710
+ 'echo "$PORT|$ROW"'
691
711
  ].join(" && ");
692
712
  const result = await sshExec2(conn, cmd, proxy);
693
713
  const output = result.stdout.trim();
694
714
  const sepIdx = output.indexOf("|");
695
715
  const port = sepIdx > 0 ? output.substring(0, sepIdx) : "";
696
- const apiKey2 = sepIdx > 0 ? output.substring(sepIdx + 1) : "";
716
+ const combined = sepIdx > 0 ? output.substring(sepIdx + 1) : "";
717
+ const [apiKey2, projectRef] = combined.split("~~");
697
718
  if (!port) {
698
719
  throw new Error(
699
720
  `Could not find webapp port for ${WA_CONTAINER}. Is the container running?`
@@ -704,7 +725,7 @@ async function discoverInstance(projectSlug, conn, proxy, sshExec2) {
704
725
  `Could not get API key for project "${projectSlug}". Check if the project slug is correct (use trigger-list).`
705
726
  );
706
727
  }
707
- return { port, apiKey: apiKey2 };
728
+ return { port, apiKey: apiKey2, projectRef: projectRef || "" };
708
729
  }
709
730
  async function fetchRunLogs(runId, conn, proxy, sshExec2) {
710
731
  const sql26 = `SELECT level, message, \\"isError\\", \\"createdAt\\" FROM \\"TaskEvent\\" WHERE \\"runId\\" = '${runId}' AND level IN ('INFO','WARN','ERROR','DEBUG','LOG','TRACE') ORDER BY \\"startTime\\" ASC LIMIT 200`;
@@ -940,6 +961,108 @@ ${rawJson.substring(0, 500)}` }] };
940
961
  return await waitForCompletion(conn, proxy, sshExec2, instance, result.id, waitSeconds);
941
962
  }
942
963
  // -----------------------------------------------------------------
964
+ case "trigger-env": {
965
+ const action = String(args2.action);
966
+ if (!["list", "get", "set", "delete"].includes(action)) {
967
+ return { content: [{ type: "text", text: "Error: action must be one of: list, get, set, delete" }] };
968
+ }
969
+ const project = String(args2.project);
970
+ const env = String(args2.env || "prod");
971
+ const instance = await discoverInstance(project, conn, proxy, sshExec2);
972
+ if (!instance.projectRef) {
973
+ return { content: [{ type: "text", text: `Error: could not resolve project ref for "${project}" (use trigger-list).` }] };
974
+ }
975
+ const apiBase = `/api/v1/projects/${instance.projectRef}/envvars/${encodeURIComponent(env)}`;
976
+ if (action === "list") {
977
+ const raw2 = await triggerApi(conn, proxy, sshExec2, instance, "GET", apiBase);
978
+ let vars;
979
+ try {
980
+ vars = JSON.parse(raw2);
981
+ } catch {
982
+ return { content: [{ type: "text", text: `Invalid API response:
983
+ ${raw2.substring(0, 500)}` }] };
984
+ }
985
+ if (!Array.isArray(vars)) {
986
+ return { content: [{ type: "text", text: `Unexpected API response:
987
+ ${raw2.substring(0, 500)}` }] };
988
+ }
989
+ if (vars.length === 0) {
990
+ return { content: [{ type: "text", text: `No environment variables for ${project}/${env}.` }] };
991
+ }
992
+ const lines = vars.map((v) => `${v.isSecret ? "[secret]" : " "} ${v.name}`).sort();
993
+ const text9 = `Env vars for ${project}/${env} (${vars.length}) \u2014 values hidden, use action="get" with a key:
994
+ ` + "-".repeat(50) + "\n" + lines.join("\n");
995
+ return { content: [{ type: "text", text: text9 }] };
996
+ }
997
+ if (action === "get") {
998
+ const key2 = String(args2.key ?? "");
999
+ if (!key2) return { content: [{ type: "text", text: 'Error: action="get" requires key' }] };
1000
+ const raw2 = await triggerApi(conn, proxy, sshExec2, instance, "GET", `${apiBase}/${encodeURIComponent(key2)}`);
1001
+ let v;
1002
+ try {
1003
+ v = JSON.parse(raw2);
1004
+ } catch {
1005
+ return { content: [{ type: "text", text: `Invalid API response:
1006
+ ${raw2.substring(0, 500)}` }] };
1007
+ }
1008
+ if (v.error) return { content: [{ type: "text", text: `Error: ${v.error}` }] };
1009
+ const includeSecret = args2.includeSecret === true;
1010
+ const shown = v.isSecret && !includeSecret ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (secret \u2014 pass includeSecret:true to reveal)" : v.value ?? "";
1011
+ const label = v.isSecret ? " [secret]" : "";
1012
+ return { content: [{ type: "text", text: `${project}/${env} ${key2}${label}
1013
+ ${shown}` }] };
1014
+ }
1015
+ if (action === "set") {
1016
+ const key2 = String(args2.key ?? "");
1017
+ if (!key2) return { content: [{ type: "text", text: 'Error: action="set" requires key' }] };
1018
+ if (args2.value === void 0) return { content: [{ type: "text", text: 'Error: action="set" requires value' }] };
1019
+ const value = String(args2.value);
1020
+ const bodyObj = { name: key2, value };
1021
+ if (typeof args2.isSecret === "boolean") bodyObj.isSecret = args2.isSecret;
1022
+ const raw2 = await triggerApi(conn, proxy, sshExec2, instance, "POST", apiBase, JSON.stringify(bodyObj));
1023
+ let resp2;
1024
+ try {
1025
+ resp2 = JSON.parse(raw2);
1026
+ } catch {
1027
+ return { content: [{ type: "text", text: `Unexpected API response:
1028
+ ${raw2.substring(0, 500)}` }] };
1029
+ }
1030
+ if (!resp2.success) {
1031
+ return { content: [{ type: "text", text: `Error: ${resp2.error || raw2.substring(0, 500)}` }] };
1032
+ }
1033
+ const verifyRaw = await triggerApi(conn, proxy, sshExec2, instance, "GET", `${apiBase}/${encodeURIComponent(key2)}`);
1034
+ let vv;
1035
+ try {
1036
+ vv = JSON.parse(verifyRaw);
1037
+ } catch {
1038
+ vv = {};
1039
+ }
1040
+ const verified = vv.value === value;
1041
+ const flag = typeof args2.isSecret === "boolean" ? ` isSecret=${args2.isSecret}` : "";
1042
+ return {
1043
+ content: [{
1044
+ type: "text",
1045
+ text: `Set ${project}/${env} ${key2}${flag}
1046
+ Verify read-back: ${verified ? "OK" : "MISMATCH \u2014 value did not persist as expected"}`
1047
+ }]
1048
+ };
1049
+ }
1050
+ const key = String(args2.key ?? "");
1051
+ if (!key) return { content: [{ type: "text", text: 'Error: action="delete" requires key' }] };
1052
+ const raw = await triggerApi(conn, proxy, sshExec2, instance, "DELETE", `${apiBase}/${encodeURIComponent(key)}`);
1053
+ let resp;
1054
+ try {
1055
+ resp = JSON.parse(raw);
1056
+ } catch {
1057
+ return { content: [{ type: "text", text: `Unexpected API response:
1058
+ ${raw.substring(0, 500)}` }] };
1059
+ }
1060
+ if (!resp.success) {
1061
+ return { content: [{ type: "text", text: `Error: ${resp.error || raw.substring(0, 500)}` }] };
1062
+ }
1063
+ return { content: [{ type: "text", text: `Deleted ${project}/${env} ${key}` }] };
1064
+ }
1065
+ // -----------------------------------------------------------------
943
1066
  default:
944
1067
  return { content: [{ type: "text", text: `Unknown trigger tool: ${name}` }] };
945
1068
  }
@@ -6970,6 +7093,75 @@ var TOOL_MODULE_MAP = {
6970
7093
  "cursor-remote-run": "cursor_remote",
6971
7094
  ...TRIGGER_TOOL_MODULE_MAP
6972
7095
  };
7096
+ var SUPERADMIN_ONLY_TOOLS = /* @__PURE__ */ new Set(["trigger-env"]);
7097
+ function resolveContentDispatch(tool, a) {
7098
+ const action = typeof a.action === "string" ? a.action : "";
7099
+ const { action: _drop, ...params } = a;
7100
+ const registry = (backendAction) => ({
7101
+ route: "content-registry",
7102
+ body: { action: backendAction, params }
7103
+ });
7104
+ const managed = (backendAction) => ({
7105
+ route: "managed-site",
7106
+ body: { action: backendAction, params }
7107
+ });
7108
+ switch (tool) {
7109
+ // Singletons keep their original names (no `action` discriminator).
7110
+ case "save_content_quality_run":
7111
+ return registry("save_content_quality_run");
7112
+ case "link_content_pack_to_ticket":
7113
+ return registry("link_content_pack_to_ticket");
7114
+ case "content-source":
7115
+ if (action === "save") return registry("save_content_source");
7116
+ if (action === "search") return registry("search_content_sources");
7117
+ if (action === "get") {
7118
+ if (typeof a.id === "string" && a.id) return registry("get_content_source_by_id");
7119
+ if (typeof a.url === "string" && a.url) return registry("get_content_source_by_url");
7120
+ return { error: "content-source action=get needs `id` or `url`" };
7121
+ }
7122
+ return { error: "content-source: action must be save | get | search" };
7123
+ case "research":
7124
+ if (action === "topic")
7125
+ return {
7126
+ route: "research-topic",
7127
+ body: {
7128
+ topic: typeof a.topic === "string" ? a.topic : void 0,
7129
+ urls: Array.isArray(a.urls) ? a.urls : void 0,
7130
+ discover: a.discover && typeof a.discover === "object" ? a.discover : void 0,
7131
+ includeRawText: typeof a.includeRawText === "boolean" ? a.includeRawText : void 0,
7132
+ maxRawTextChars: typeof a.maxRawTextChars === "number" ? a.maxRawTextChars : void 0
7133
+ }
7134
+ };
7135
+ if (action === "save") return registry("save_research_pack");
7136
+ if (action === "get") return registry("get_research_pack");
7137
+ return { error: "research: action must be topic | save | get" };
7138
+ case "content-corpus":
7139
+ if (action === "save") return registry("save_content_corpus_item");
7140
+ if (action === "list") return registry("list_project_content_corpus");
7141
+ if (action === "context") return registry("get_content_context_for_project");
7142
+ return { error: "content-corpus: action must be save | list | context" };
7143
+ case "content-snapshot":
7144
+ if (action === "list") return registry("list_content_snapshots");
7145
+ if (action === "prune") return registry("prune_content_snapshots");
7146
+ return { error: "content-snapshot: action must be list | prune" };
7147
+ case "site-candidate":
7148
+ if (action === "add") return registry("add_site_candidates");
7149
+ if (action === "list") return registry("list_site_candidates");
7150
+ if (action === "set-status") return registry("set_candidate_status");
7151
+ return { error: "site-candidate: action must be add | list | set-status" };
7152
+ case "managed-site":
7153
+ if (action === "register") return managed("register_managed_site");
7154
+ if (action === "update") return managed("update_managed_site");
7155
+ if (action === "list") return managed("list_managed_sites");
7156
+ return { error: "managed-site: action must be register | update | list" };
7157
+ case "content-sync":
7158
+ if (action === "trigger") return managed("trigger_content_sync");
7159
+ if (action === "status") return managed("get_content_sync_status");
7160
+ return { error: "content-sync: action must be trigger | status" };
7161
+ default:
7162
+ return { error: `unknown content tool ${tool}` };
7163
+ }
7164
+ }
6973
7165
  function normalizeAllowedTools(value) {
6974
7166
  if (!Array.isArray(value)) return null;
6975
7167
  const tools = value.filter((t) => typeof t === "string").map((t) => t.trim()).filter(Boolean);
@@ -8596,30 +8788,19 @@ var NO_FOOTER_TOOLS = /* @__PURE__ */ new Set();
8596
8788
  var RAW_JSON_TOOLS = /* @__PURE__ */ new Set([
8597
8789
  "get_mg_dashboard_commits",
8598
8790
  "extract_article",
8599
- "research_topic",
8600
- // Content registry / corpus (2026-DASMG-041) compact JSON consumed verbatim.
8601
- "save_content_source",
8602
- "get_content_source_by_url",
8603
- "get_content_source_by_id",
8604
- "search_content_sources",
8605
- "save_research_pack",
8606
- "get_research_pack",
8607
- "save_content_corpus_item",
8608
- "list_project_content_corpus",
8609
- "get_content_context_for_project",
8791
+ // Content family (2026-DASMG-040/041/042) — compact JSON consumed verbatim.
8792
+ // Consolidated action-based tools (the Cursor surface); each proxies to the
8793
+ // backend content-registry / managed-site / research-topic dispatchers, which
8794
+ // still speak the original fine-grained action names.
8795
+ "research",
8796
+ "content-source",
8797
+ "content-corpus",
8798
+ "content-snapshot",
8799
+ "site-candidate",
8610
8800
  "save_content_quality_run",
8611
- "list_content_snapshots",
8612
8801
  "link_content_pack_to_ticket",
8613
- "prune_content_snapshots",
8614
- "add_site_candidates",
8615
- "list_site_candidates",
8616
- "set_candidate_status",
8617
- // Managed site content sync (2026-DASMG-042) — compact JSON consumed verbatim.
8618
- "register_managed_site",
8619
- "update_managed_site",
8620
- "list_managed_sites",
8621
- "trigger_content_sync",
8622
- "get_content_sync_status"
8802
+ "managed-site",
8803
+ "content-sync"
8623
8804
  ]);
8624
8805
  var TOOL_CACHE_TTL_MS = {
8625
8806
  "list-servers": 6e4,
@@ -10770,7 +10951,7 @@ var TOOLS = [
10770
10951
  },
10771
10952
  {
10772
10953
  name: "extract_article",
10773
- description: "Extract readable article/content data from ONE PUBLIC URL, with a browser-render fallback for pages that block a normal fetch (403) or only render with JavaScript (ticket 2026-DASMG-039). Returns title, author, publishedAt, source, summary, facts[], optional rawText (capped), canonicalUrl, the `extractionMethod` used (http | browser | jsonld | rss | amp | metadata_only) and an explicit `limitations` array (e.g. blocked_without_browser, paywall_detected, partial_content, rawtext_truncated). PUBLIC URLs only \u2014 SSRF-guarded (private/loopback/metadata addresses rejected), no paywall bypass, no credentials; the source/canonical URL is always preserved. Use it to turn a news/article link into safe factual source material for agents; it never auto-publishes or rewrites. For several sources + cross-checking, use research_topic.",
10954
+ description: "Extract readable article/content data from ONE PUBLIC URL, with a browser-render fallback for pages that block a normal fetch (403) or only render with JavaScript (ticket 2026-DASMG-039). Returns title, author, publishedAt, source, summary, facts[], optional rawText (capped), canonicalUrl, the `extractionMethod` used (http | browser | jsonld | rss | amp | metadata_only) and an explicit `limitations` array (e.g. blocked_without_browser, paywall_detected, partial_content, rawtext_truncated). PUBLIC URLs only \u2014 SSRF-guarded (private/loopback/metadata addresses rejected), no paywall bypass, no credentials; the source/canonical URL is always preserved. Use it to turn a news/article link into safe factual source material for agents; it never auto-publishes or rewrites. For several sources + cross-checking, use `research` with action=topic.",
10774
10955
  inputSchema: {
10775
10956
  type: "object",
10776
10957
  properties: {
@@ -10790,30 +10971,44 @@ var TOOLS = [
10790
10971
  required: ["url"]
10791
10972
  }
10792
10973
  },
10974
+ // ----- Research + evidence packs (2026-DASMG-040/041), consolidated -----
10793
10975
  {
10794
- name: "research_topic",
10795
- description: "Turn SEVERAL public sources on one topic into a single cross-checked, sourced 'evidence pack' (ticket 2026-DASMG-040). Composes extract_article per URL and aggregates: collect, extract, cross-check, then hand a downstream agent a safe base to write ORIGINAL content from. Modes: curated (`urls` + optional `topic`), discovery (`topic` + `discover.enabled` \u2014 finds source URLs for you), hybrid (`urls` + `discover` \u2014 curated seeds topped up by discovery). Discovery runs open-web search FIRST (unscoped + site-scoped when allowDomains is set); allowDomains is a prefer/guarantee set, not a hard filter on search. Sitemap top-up stays within allowDomains. Reported via `discovery.backend` (search|sitemap|both|unavailable) and per-source `discoveryOrigin`. Optional `discover.sourcePolicy: official_only`. Returns `sources` (id,url,\u2026,discoveryOrigin,authorityTier incl. grid_operator), `discovery` report, `verifiedFacts`, `conflicts`, `uniqueClaims`, `openQuestions`, `verbatimWarnings`, and pack-level `limitations`. Structured EVIDENCE ONLY \u2014 never ready-to-publish prose or brand voice; every claim traces back to a source id. PUBLIC URLs only (inherits the SSRF guard). Persist + reuse with save_research_pack / get_research_pack (2026-DASMG-041).",
10976
+ name: "research",
10977
+ description: 'Research + evidence-pack toolkit (2026-DASMG-040/041). Pick the operation with `action`:\n- "topic": turn SEVERAL public sources on one topic into a single cross-checked, sourced \'evidence pack\'. Composes extract_article per URL: collect, extract, cross-check, then hand a downstream agent a safe base to write ORIGINAL content from. Modes: curated (`urls` + optional `topic`), discovery (`topic` + `discover.enabled` \u2014 finds source URLs for you), hybrid (`urls` + `discover`). Returns `sources`, `discovery` report, `verifiedFacts`, `conflicts`, `uniqueClaims`, `openQuestions`, `verbatimWarnings`, `limitations`. Structured EVIDENCE ONLY \u2014 never ready-to-publish prose; every claim traces to a source id. PUBLIC URLs only (SSRF-guarded).\n- "save": persist an evidence pack. Pass an already-built `pack`, or `urls`/`topic` (+ optional `discover`) to research then save. Each source is deduped into content_sources and linked so packs are reusable without re-crawling. Returns pack id + compact counts + linked source ids.\n- "get": fetch a stored pack by `id`. Compact by default; set `includeSources` / `includeClaims` for the full arrays.',
10796
10978
  inputSchema: {
10797
10979
  type: "object",
10798
10980
  properties: {
10981
+ action: {
10982
+ type: "string",
10983
+ enum: ["topic", "save", "get"],
10984
+ description: "topic = build a pack; save = persist a pack; get = fetch a stored pack."
10985
+ },
10986
+ id: {
10987
+ type: "string",
10988
+ description: "action=get: content_evidence_packs.id (uuid)."
10989
+ },
10799
10990
  topic: {
10800
10991
  type: "string",
10801
- description: "The topic the evidence pack is about (used for labelling, and as the search query in discovery mode)."
10992
+ description: "action=topic/save: the topic the pack is about (also the search query in discovery mode)."
10802
10993
  },
10803
10994
  urls: {
10804
10995
  type: "array",
10805
10996
  items: { type: "string" },
10806
- description: "Curated list of public http(s) URLs to extract and cross-check (max 20)."
10997
+ description: "action=topic/save: curated public http(s) URLs to extract and cross-check (max 20)."
10998
+ },
10999
+ pack: {
11000
+ type: "object",
11001
+ description: "action=save: an already-built pack payload (skips re-researching)."
10807
11002
  },
10808
11003
  discover: {
10809
11004
  type: "object",
10810
- description: "Discovery options. enabled=true auto-finds source URLs for `topic` via an env-gated web-search backend plus a keyless sitemap crawl over allowDomains. Curated `urls` are kept and topped up to maxSources; degrades gracefully (discovery_unavailable) when no backend is configured.",
11005
+ description: "action=topic/save: discovery options. enabled=true auto-finds source URLs for `topic` via an env-gated web-search backend + keyless sitemap crawl over allowDomains. Curated `urls` are kept and topped up to maxSources; degrades gracefully when no backend is configured.",
10811
11006
  properties: {
10812
11007
  enabled: { type: "boolean", description: "Turn discovery on (default off)." },
10813
11008
  maxSources: { type: "number", description: "Cap on total sources, curated + discovered (1-12, default 8)." },
10814
- recencyDays: { type: "number", description: "Prefer/keep sources no older than this (applied to sitemap lastmod)." },
11009
+ recencyDays: { type: "number", description: "Prefer/keep sources no older than this (sitemap lastmod)." },
10815
11010
  locale: { type: "string", description: "Locale hint, e.g. nl-NL." },
10816
- allowDomains: { type: "array", items: { type: "string" }, description: "Prefer/guarantee domains for search ranking; targets for sitemap top-up." },
11011
+ allowDomains: { type: "array", items: { type: "string" }, description: "Prefer/guarantee domains; targets for sitemap top-up." },
10817
11012
  blockDomains: { type: "array", items: { type: "string" }, description: "Domains to exclude from discovery." },
10818
11013
  sourcePolicy: {
10819
11014
  type: "string",
@@ -10824,24 +11019,21 @@ var TOOLS = [
10824
11019
  },
10825
11020
  includeRawText: {
10826
11021
  type: "boolean",
10827
- description: "Reserved for compatibility; claim mining always reads body text up to maxRawTextChars."
11022
+ description: "action=topic: reserved; claim mining always reads body text up to maxRawTextChars."
10828
11023
  },
10829
11024
  maxRawTextChars: {
10830
11025
  type: "number",
10831
- description: "Per-source body cap used for claim mining (default 4000, max 20000)."
10832
- }
10833
- }
10834
- }
10835
- },
10836
- // ----- Content registry / corpus persistence (2026-DASMG-041) -----
10837
- {
10838
- name: "save_content_source",
10839
- description: "Persist (dedupe + upsert) ONE fetched source \u2014 the durable companion to extract_article (2026-DASMG-041). Pass a `url` (re-extracts internally) and/or already-extracted fields (title/summary/facts/fullText). Dedupes on canonical URL then text hash, and snapshots the previous version into content_snapshots when the text changed. External sources keep their `source_type` so they stay labelled as evidence, never own content. Returns the stored id + a compact summary + whether it was created/updated/snapshotted.",
10840
- inputSchema: {
10841
- type: "object",
10842
- properties: {
10843
- url: { type: "string", description: "Source URL (required)." },
10844
- canonicalUrl: { type: "string", description: "Canonical URL used for dedupe." },
11026
+ description: "action=topic: per-source body cap for claim mining (default 4000, max 20000)."
11027
+ },
11028
+ includeSources: { type: "boolean", description: "action=get: return the source list." },
11029
+ includeClaims: {
11030
+ type: "boolean",
11031
+ description: "action=get: return verified facts / conflicts / unique claims."
11032
+ },
11033
+ locale: { type: "string", description: "action=save: locale of the pack." },
11034
+ projectKey: { type: "string", description: "action=save: project identifier." },
11035
+ siteKey: { type: "string", description: "action=save: site identifier." },
11036
+ ticketNumber: { type: "string", description: "action=save: related ticket number." },
10845
11037
  sourceType: {
10846
11038
  type: "string",
10847
11039
  enum: [
@@ -10854,113 +11046,31 @@ var TOOLS = [
10854
11046
  "supplier",
10855
11047
  "unknown"
10856
11048
  ],
10857
- description: "Provenance label. own_site/client_site = managed content; the rest are evidence only."
11049
+ description: "action=save: provenance label for linked sources."
10858
11050
  },
10859
- title: { type: "string", description: "Title." },
10860
- author: { type: "string", description: "Author." },
10861
- publishedAt: { type: "string", description: "Publish date (any parseable format)." },
10862
- summary: { type: "string", description: "Short factual summary." },
10863
- facts: { type: "array", items: { type: "string" }, description: "Key facts." },
10864
- limitations: { type: "array", items: { type: "string" } },
10865
- fullText: { type: "string", description: "Extracted body text." },
10866
- language: { type: "string", description: "Language code." },
10867
- projectKey: { type: "string", description: "Project/site identifier." },
10868
- siteKey: { type: "string", description: "Site identifier." },
10869
- ticketNumber: { type: "string", description: "Related ticket number." },
10870
- metadata: { type: "object" },
10871
- reExtract: {
10872
- type: "boolean",
10873
- description: "Force a re-fetch even when fields are supplied."
10874
- }
11051
+ metadata: { type: "object", description: "action=save: arbitrary metadata." }
10875
11052
  },
10876
- required: ["url"]
10877
- }
10878
- },
10879
- {
10880
- name: "get_content_source_by_url",
10881
- description: "Look up a stored source by URL or canonical URL (2026-DASMG-041). Compact by default (id + summary + counts); set `includeFullText` for the body. Use to answer 'was this link already scanned?' before re-extracting.",
10882
- inputSchema: {
10883
- type: "object",
10884
- properties: {
10885
- url: { type: "string", description: "URL or canonical URL." },
10886
- includeFullText: { type: "boolean", description: "Return full_text." }
10887
- },
10888
- required: ["url"]
10889
- }
10890
- },
10891
- {
10892
- name: "get_content_source_by_id",
10893
- description: "Fetch a stored source by id (2026-DASMG-041). Compact by default; set `includeFullText` for the body.",
10894
- inputSchema: {
10895
- type: "object",
10896
- properties: {
10897
- id: { type: "string", description: "content_sources.id (uuid)." },
10898
- includeFullText: { type: "boolean", description: "Return full_text." }
10899
- },
10900
- required: ["id"]
11053
+ required: ["action"]
10901
11054
  }
10902
11055
  },
11056
+ // ----- Content sources registry (2026-DASMG-041), consolidated -----
10903
11057
  {
10904
- name: "search_content_sources",
10905
- description: "Full-text + filter search over stored sources (2026-DASMG-041): query (title/summary/body), domain, sourceType, projectKey, ticketNumber, dateFrom/dateTo. Compact, paginated results. Use to reuse evidence already in the registry.",
11058
+ name: "content-source",
11059
+ description: 'Stored external/evidence sources registry (2026-DASMG-041). Pick the operation with `action`:\n- "save": persist (dedupe + upsert) ONE fetched source \u2014 the durable companion to extract_article. Pass a `url` (re-extracts internally) and/or already-extracted fields (title/summary/facts/fullText). Dedupes on canonical URL then text hash, snapshots the previous version when text changed. Returns the stored id + compact summary + created/updated/snapshotted. Required: url.\n- "get": look up ONE stored source by `id` OR by `url`/canonical url (\'was this link already scanned?\'). Compact by default; set `includeFullText` for the body. Required: id or url.\n- "search": full-text + filter search over stored sources (query, domain, sourceType, projectKey, ticketNumber, dateFrom/dateTo). Compact, paginated. Use to reuse evidence already in the registry.',
10906
11060
  inputSchema: {
10907
11061
  type: "object",
10908
11062
  properties: {
10909
- query: { type: "string", description: "Free-text search (title/summary/body)." },
10910
- domain: { type: "string", description: "Exact domain filter." },
10911
- sourceType: {
11063
+ action: {
10912
11064
  type: "string",
10913
- enum: [
10914
- "own_site",
10915
- "client_site",
10916
- "external_source",
10917
- "competitor",
10918
- "news",
10919
- "government",
10920
- "supplier",
10921
- "unknown"
10922
- ]
11065
+ enum: ["save", "get", "search"],
11066
+ description: "save = upsert one source; get = fetch by id/url; search = filtered search."
10923
11067
  },
10924
- projectKey: { type: "string", description: "Project filter." },
10925
- ticketNumber: { type: "string", description: "Ticket filter." },
10926
- dateFrom: { type: "string", description: "Last-seen lower bound (ISO/date)." },
10927
- dateTo: { type: "string", description: "Last-seen upper bound (ISO/date)." },
10928
- limit: { type: "integer", description: "Max rows (default 20, max 100)." },
10929
- offset: { type: "integer", description: "Pagination offset." },
10930
- includeFullText: { type: "boolean", description: "Return full_text per row." }
10931
- }
10932
- }
10933
- },
10934
- {
10935
- name: "save_research_pack",
10936
- description: "Persist a research_topic evidence pack (2026-DASMG-041): cross-checked verified facts, conflicts, unique claims, open questions. Pass an already-built `pack`, or `urls`/`topic` to research then save. Add `discover` (with a topic) to auto-find sources before saving \u2014 the same discovery research_topic does (2026-DASMG-040). Each source is deduped into content_sources and linked via the join table, so packs can be reused without re-crawling. Returns the pack id + compact counts + linked source ids.",
10937
- inputSchema: {
10938
- type: "object",
10939
- properties: {
10940
- pack: { type: "object", description: "A research_topic result payload." },
10941
- urls: {
10942
- type: "array",
10943
- items: { type: "string" },
10944
- description: "URLs to research if no pack is given."
10945
- },
10946
- topic: { type: "string", description: "Topic to research if no pack is given." },
10947
- discover: {
10948
- type: "object",
10949
- description: "Discovery options (same shape as research_topic). With a topic, enabled=true auto-finds source URLs before saving. Ignored when `pack` is supplied.",
10950
- properties: {
10951
- enabled: { type: "boolean" },
10952
- maxSources: { type: "number" },
10953
- recencyDays: { type: "number" },
10954
- locale: { type: "string" },
10955
- allowDomains: { type: "array", items: { type: "string" } },
10956
- blockDomains: { type: "array", items: { type: "string" } },
10957
- sourcePolicy: { type: "string", enum: ["official_only"] }
10958
- }
11068
+ id: { type: "string", description: "action=get: content_sources.id (uuid)." },
11069
+ url: {
11070
+ type: "string",
11071
+ description: "action=save (required) / action=get: source URL or canonical URL."
10959
11072
  },
10960
- locale: { type: "string", description: "Locale of the pack." },
10961
- projectKey: { type: "string", description: "Project identifier." },
10962
- siteKey: { type: "string", description: "Site identifier." },
10963
- ticketNumber: { type: "string", description: "Related ticket number." },
11073
+ canonicalUrl: { type: "string", description: "action=save: canonical URL used for dedupe." },
10964
11074
  sourceType: {
10965
11075
  type: "string",
10966
11076
  enum: [
@@ -10972,38 +11082,55 @@ var TOOLS = [
10972
11082
  "government",
10973
11083
  "supplier",
10974
11084
  "unknown"
10975
- ]
11085
+ ],
11086
+ description: "Provenance label. own_site/client_site = managed content; the rest are evidence only. (save/search filter)"
10976
11087
  },
10977
- metadata: { type: "object" }
10978
- }
10979
- }
10980
- },
10981
- {
10982
- name: "get_research_pack",
10983
- description: "Fetch a stored evidence pack by id (2026-DASMG-041). Compact by default (counts + provenance); set `includeSources` / `includeClaims` for the full arrays.",
10984
- inputSchema: {
10985
- type: "object",
10986
- properties: {
10987
- id: { type: "string", description: "content_evidence_packs.id (uuid)." },
10988
- includeSources: { type: "boolean", description: "Return the source list." },
10989
- includeClaims: {
11088
+ title: { type: "string", description: "action=save: title." },
11089
+ author: { type: "string", description: "action=save: author." },
11090
+ publishedAt: { type: "string", description: "action=save: publish date (any parseable format)." },
11091
+ summary: { type: "string", description: "action=save: short factual summary." },
11092
+ facts: { type: "array", items: { type: "string" }, description: "action=save: key facts." },
11093
+ limitations: { type: "array", items: { type: "string" } },
11094
+ fullText: { type: "string", description: "action=save: extracted body text." },
11095
+ language: { type: "string", description: "action=save: language code." },
11096
+ projectKey: { type: "string", description: "Project/site identifier (save/search)." },
11097
+ siteKey: { type: "string", description: "action=save: site identifier." },
11098
+ ticketNumber: { type: "string", description: "Related ticket number (save/search filter)." },
11099
+ metadata: { type: "object" },
11100
+ reExtract: {
11101
+ type: "boolean",
11102
+ description: "action=save: force a re-fetch even when fields are supplied."
11103
+ },
11104
+ query: { type: "string", description: "action=search: free-text search (title/summary/body)." },
11105
+ domain: { type: "string", description: "action=search: exact domain filter." },
11106
+ dateFrom: { type: "string", description: "action=search: last-seen lower bound (ISO/date)." },
11107
+ dateTo: { type: "string", description: "action=search: last-seen upper bound (ISO/date)." },
11108
+ limit: { type: "integer", description: "action=search: max rows (default 20, max 100)." },
11109
+ offset: { type: "integer", description: "action=search: pagination offset." },
11110
+ includeFullText: {
10990
11111
  type: "boolean",
10991
- description: "Return verified facts / conflicts / unique claims."
11112
+ description: "action=get/search: return full_text (per row for search)."
10992
11113
  }
10993
11114
  },
10994
- required: ["id"]
11115
+ required: ["action"]
10995
11116
  }
10996
11117
  },
11118
+ // ----- Own/managed content corpus (2026-DASMG-041), consolidated -----
10997
11119
  {
10998
- name: "save_content_corpus_item",
10999
- description: "Upsert ONE own/managed page into the corpus (2026-DASMG-041), keyed by (projectKey, canonical url) \u2014 the current state of content you own. Pass a `url` (re-extracts) and/or title/h1/headings/fullText/internalLinks. Snapshots the previous version when the text changed. Use this for your/your client's pages, not external evidence.",
11120
+ name: "content-corpus",
11121
+ description: 'Your/your client\'s OWN managed pages (the corpus), 2026-DASMG-041. Pick the operation with `action`:\n- "save": upsert ONE own/managed page keyed by (projectKey, canonical url) \u2014 the current state of content you own. Pass a `url` (re-extracts) and/or title/h1/headings/fullText/internalLinks. Snapshots the previous version when text changed. For your pages, not external evidence. Required: projectKey, url.\n- "list": list corpus items for a project, filter by domain/sourceType/query. Compact, paginated. Required: projectKey.\n- "context": aggregate brief for a content agent \u2014 recent corpus items, recent evidence packs and related sources for a project (optionally focused by topic/url), plus totals. One call to brief an agent on what already exists. Required: projectKey.',
11000
11122
  inputSchema: {
11001
11123
  type: "object",
11002
11124
  properties: {
11003
- projectKey: { type: "string", description: "Project identifier (required)." },
11004
- url: { type: "string", description: "Page URL (required)." },
11005
- siteKey: { type: "string", description: "Site identifier." },
11006
- canonicalUrl: { type: "string", description: "Canonical URL." },
11125
+ action: {
11126
+ type: "string",
11127
+ enum: ["save", "list", "context"],
11128
+ description: "save = upsert one own page; list = list corpus items; context = aggregate agent brief."
11129
+ },
11130
+ projectKey: { type: "string", description: "Project identifier (required for all actions)." },
11131
+ url: { type: "string", description: "action=save (required) / action=context: page URL focus." },
11132
+ siteKey: { type: "string", description: "action=save: site identifier." },
11133
+ canonicalUrl: { type: "string", description: "action=save: canonical URL." },
11007
11134
  sourceType: {
11008
11135
  type: "string",
11009
11136
  enum: [
@@ -11016,76 +11143,42 @@ var TOOLS = [
11016
11143
  "supplier",
11017
11144
  "unknown"
11018
11145
  ],
11019
- description: "Defaults to own_site."
11146
+ description: "action=save: defaults to own_site. action=list: filter."
11020
11147
  },
11021
- pagePath: { type: "string", description: "Path within the site." },
11022
- title: { type: "string", description: "Title." },
11023
- h1: { type: "string", description: "Main heading." },
11148
+ pagePath: { type: "string", description: "action=save: path within the site." },
11149
+ title: { type: "string", description: "action=save: title." },
11150
+ h1: { type: "string", description: "action=save: main heading." },
11024
11151
  headings: {
11025
11152
  type: "array",
11026
11153
  items: { type: "object" },
11027
- description: "Headings ({level,text})."
11154
+ description: "action=save: headings ({level,text})."
11028
11155
  },
11029
- wordCount: { type: "integer", description: "Word count." },
11030
- fullText: { type: "string", description: "Full page text." },
11156
+ wordCount: { type: "integer", description: "action=save: word count." },
11157
+ fullText: { type: "string", description: "action=save: full page text." },
11031
11158
  internalLinks: {
11032
11159
  type: "array",
11033
11160
  items: { type: "object" },
11034
- description: "Internal links ({url,text})."
11161
+ description: "action=save: internal links ({url,text})."
11035
11162
  },
11036
11163
  externalLinks: { type: "array", items: { type: "object" } },
11037
- language: { type: "string", description: "Language code." },
11038
- ticketNumber: { type: "string", description: "Related ticket number." },
11164
+ language: { type: "string", description: "action=save: language code." },
11165
+ ticketNumber: { type: "string", description: "action=save: related ticket number." },
11039
11166
  metadata: { type: "object" },
11040
11167
  reExtract: {
11041
11168
  type: "boolean",
11042
- description: "Force a re-fetch even when fields are supplied."
11043
- }
11044
- },
11045
- required: ["projectKey", "url"]
11046
- }
11047
- },
11048
- {
11049
- name: "list_project_content_corpus",
11050
- description: "List corpus items for a project (2026-DASMG-041), filter by domain/sourceType/query. Compact, paginated. Use to see content coverage and reuse own pages.",
11051
- inputSchema: {
11052
- type: "object",
11053
- properties: {
11054
- projectKey: { type: "string", description: "Project identifier (required)." },
11055
- domain: { type: "string", description: "Domain filter." },
11056
- sourceType: {
11057
- type: "string",
11058
- enum: [
11059
- "own_site",
11060
- "client_site",
11061
- "external_source",
11062
- "competitor",
11063
- "news",
11064
- "government",
11065
- "supplier",
11066
- "unknown"
11067
- ]
11169
+ description: "action=save: force a re-fetch even when fields are supplied."
11068
11170
  },
11069
- query: { type: "string", description: "Free-text search." },
11070
- limit: { type: "integer", description: "Max rows (default 20)." },
11071
- offset: { type: "integer", description: "Pagination offset." },
11072
- includeFullText: { type: "boolean", description: "Return full_text per row." }
11073
- },
11074
- required: ["projectKey"]
11075
- }
11076
- },
11077
- {
11078
- name: "get_content_context_for_project",
11079
- description: "Aggregate context for a content agent (2026-DASMG-041): recent corpus items, recent evidence packs and related sources for a project (optionally focused by topic or url), plus totals. One call to brief an agent on what already exists before it writes.",
11080
- inputSchema: {
11081
- type: "object",
11082
- properties: {
11083
- projectKey: { type: "string", description: "Project identifier (required)." },
11084
- topic: { type: "string", description: "Optional topic focus." },
11085
- url: { type: "string", description: "Optional url focus." },
11086
- limit: { type: "integer", description: "Max items per section (default 10)." }
11171
+ domain: { type: "string", description: "action=list: domain filter." },
11172
+ query: { type: "string", description: "action=list: free-text search." },
11173
+ topic: { type: "string", description: "action=context: optional topic focus." },
11174
+ limit: {
11175
+ type: "integer",
11176
+ description: "action=list: max rows (default 20). action=context: max items per section (default 10)."
11177
+ },
11178
+ offset: { type: "integer", description: "action=list: pagination offset." },
11179
+ includeFullText: { type: "boolean", description: "action=list: return full_text per row." }
11087
11180
  },
11088
- required: ["projectKey"]
11181
+ required: ["action", "projectKey"]
11089
11182
  }
11090
11183
  },
11091
11184
  {
@@ -11116,19 +11209,31 @@ var TOOLS = [
11116
11209
  required: ["tool"]
11117
11210
  }
11118
11211
  },
11212
+ // ----- Content snapshots / version history (2026-DASMG-041), consolidated -----
11119
11213
  {
11120
- name: "list_content_snapshots",
11121
- description: "List historical snapshots for a corpus item or source (2026-DASMG-041) by id, url or canonical url, newest first. Compact; set `includeFullText` for the captured body. Use to see how content changed over time.",
11214
+ name: "content-snapshot",
11215
+ description: 'Historical version snapshots of corpus items / sources (2026-DASMG-041). Pick the operation with `action`:\n- "list": list snapshots for a corpus item or source by id, url or canonical url, newest first. Compact; set `includeFullText` for the captured body. Use to see how content changed over time.\n- "prune": retention cleanup \u2014 keep the newest N snapshots per item and drop the rest older than the retention window (defaults keepPerItem 20, olderThanDays 365). Returns how many were pruned.',
11122
11216
  inputSchema: {
11123
11217
  type: "object",
11124
11218
  properties: {
11125
- corpusItemId: { type: "string", description: "content_corpus_items.id." },
11126
- contentSourceId: { type: "string", description: "content_sources.id." },
11127
- url: { type: "string", description: "URL to match." },
11128
- canonicalUrl: { type: "string", description: "Canonical URL to match." },
11129
- limit: { type: "integer", description: "Max rows (default 20)." },
11130
- includeFullText: { type: "boolean", description: "Return captured full_text." }
11131
- }
11219
+ action: {
11220
+ type: "string",
11221
+ enum: ["list", "prune"],
11222
+ description: "list = show snapshots; prune = retention cleanup."
11223
+ },
11224
+ corpusItemId: { type: "string", description: "action=list: content_corpus_items.id." },
11225
+ contentSourceId: { type: "string", description: "action=list: content_sources.id." },
11226
+ url: { type: "string", description: "action=list: URL to match." },
11227
+ canonicalUrl: { type: "string", description: "action=list: canonical URL to match." },
11228
+ limit: { type: "integer", description: "action=list: max rows (default 20)." },
11229
+ includeFullText: { type: "boolean", description: "action=list: return captured full_text." },
11230
+ keepPerItem: { type: "integer", description: "action=prune: snapshots to keep per item (default 20)." },
11231
+ olderThanDays: {
11232
+ type: "integer",
11233
+ description: "action=prune: only prune snapshots older than this many days (default 365)."
11234
+ }
11235
+ },
11236
+ required: ["action"]
11132
11237
  }
11133
11238
  },
11134
11239
  {
@@ -11143,31 +11248,41 @@ var TOOLS = [
11143
11248
  required: ["packId", "ticketNumber"]
11144
11249
  }
11145
11250
  },
11251
+ // ----- Content/site candidates (2026-DASMG-041), consolidated -----
11146
11252
  {
11147
- name: "prune_content_snapshots",
11148
- description: "Retention cleanup (2026-DASMG-041): keep the newest N snapshots per item and drop the rest older than the retention window. Defaults: keepPerItem 20, olderThanDays 365. Returns how many were pruned.",
11253
+ name: "site-candidate",
11254
+ description: 'Which site(s) a stored source/pack could be used on (2026-DASMG-041). Pick the operation with `action`:\n- "add": record candidates \u2014 attach one or more to a contentSourceId and/or packId; each candidate targets a managedSiteId (preferred) or projectKey/domain. Re-adding the same (subject, target) updates instead of duplicating. Required: candidates.\n- "list": list candidates, filter by managedSiteId / projectKey / domain, contentSourceId, packId, status. Use status=candidate to find unused content suggested for a site. Compact, paginated; includes source title / pack topic.\n- "set-status": update a candidate\'s status. status=used marks it published on the site (stamps usedAt + usedCorpusItemId / usedUrl); rejected = not a fit. Required: id, status.',
11149
11255
  inputSchema: {
11150
11256
  type: "object",
11151
11257
  properties: {
11152
- keepPerItem: { type: "integer", description: "Snapshots to keep per item (default 20)." },
11153
- olderThanDays: {
11154
- type: "integer",
11155
- description: "Only prune snapshots older than this many days (default 365)."
11156
- }
11157
- }
11158
- }
11159
- },
11160
- {
11161
- name: "add_site_candidates",
11162
- description: "Record which site(s) a stored source or research pack could be interesting for, for reuse later (2026-DASMG-041). Attach one or more candidates to a contentSourceId and/or packId; each candidate targets a managedSiteId (preferred) or a projectKey/domain. Re-adding the same (subject, target) updates instead of duplicating.",
11163
- inputSchema: {
11164
- type: "object",
11165
- properties: {
11166
- contentSourceId: { type: "string", description: "content_sources.id (the source to suggest)." },
11167
- packId: { type: "string", description: "content_evidence_packs.id (the pack to suggest)." },
11258
+ action: {
11259
+ type: "string",
11260
+ enum: ["add", "list", "set-status"],
11261
+ description: "add = record candidates; list = list candidates; set-status = update one candidate."
11262
+ },
11263
+ id: { type: "string", description: "action=set-status: content_site_candidates.id (uuid)." },
11264
+ status: {
11265
+ type: "string",
11266
+ enum: ["candidate", "used", "rejected"],
11267
+ description: "action=set-status: new status (required). action=list: filter \u2014 candidate = suggested, used = published, rejected = not a fit."
11268
+ },
11269
+ usedCorpusItemId: {
11270
+ type: "string",
11271
+ description: "action=set-status: content_corpus_items.id of the page produced (when used)."
11272
+ },
11273
+ usedUrl: { type: "string", description: "action=set-status: URL of the page produced (when used)." },
11274
+ reason: { type: "string", description: "action=set-status: optional note for the status change." },
11275
+ contentSourceId: {
11276
+ type: "string",
11277
+ description: "action=add: the source to suggest. action=list: filter."
11278
+ },
11279
+ packId: {
11280
+ type: "string",
11281
+ description: "action=add: content_evidence_packs.id to suggest. action=list: filter."
11282
+ },
11168
11283
  candidates: {
11169
11284
  type: "array",
11170
- description: "Target sites. Each needs a managedSiteId, projectKey or domain.",
11285
+ description: "action=add: target sites. Each needs a managedSiteId, projectKey or domain.",
11171
11286
  items: {
11172
11287
  type: "object",
11173
11288
  properties: {
@@ -11180,66 +11295,44 @@ var TOOLS = [
11180
11295
  }
11181
11296
  }
11182
11297
  },
11183
- ticketNumber: { type: "string", description: "Related ticket number." },
11298
+ managedSiteId: { type: "string", description: "action=list: managed_sites.id filter." },
11299
+ projectKey: { type: "string", description: "action=list: project filter." },
11300
+ domain: { type: "string", description: "action=list: domain filter." },
11301
+ limit: { type: "integer", description: "action=list: max rows (default 20, max 100)." },
11302
+ offset: { type: "integer", description: "action=list: pagination offset." },
11303
+ ticketNumber: { type: "string", description: "action=add: related ticket number." },
11184
11304
  metadata: { type: "object" }
11185
11305
  },
11186
- required: ["candidates"]
11306
+ required: ["action"]
11187
11307
  }
11188
11308
  },
11309
+ // ----- Managed sites (2026-DASMG-042), consolidated -----
11189
11310
  {
11190
- name: "list_site_candidates",
11191
- description: "List content/site candidates (2026-DASMG-041): filter by managedSiteId / projectKey / domain, contentSourceId, packId, status. Use status=candidate to find unused content suggested for a site. Compact; paginated; includes the source title / pack topic for context.",
11311
+ name: "managed-site",
11312
+ description: 'Sites registered for content sync (2026-DASMG-042). Pick the operation with `action`:\n- "register": register (or upsert by projectKey+domain) a site: domain, optional repo/branch + sitemapUrl, syncMode and a git pathUrlMap. Optional + additive \u2014 external/unmanaged sites still work via extract_article without this. Enables git-push/cron/manual ingestion that re-indexes only CHANGED pages. Required: projectKey, domain.\n- "update": update an existing site by id \u2014 only provided fields change (enable/disable, switch syncMode, set sitemapUrl/pathUrlMap, \u2026). Required: id.\n- "list": list managed sites, filter by projectKey/domain/repo/enabledOnly. Compact, paginated.\nTrigger/poll ingestion runs with the `content-sync` tool.',
11192
11313
  inputSchema: {
11193
11314
  type: "object",
11194
11315
  properties: {
11195
- managedSiteId: { type: "string", description: "managed_sites.id filter." },
11196
- projectKey: { type: "string", description: "Project filter." },
11197
- domain: { type: "string", description: "Domain filter." },
11198
- contentSourceId: { type: "string", description: "content_sources.id filter." },
11199
- packId: { type: "string", description: "content_evidence_packs.id filter." },
11200
- status: {
11316
+ action: {
11201
11317
  type: "string",
11202
- enum: ["candidate", "used", "rejected"],
11203
- description: "candidate = suggested, used = published there, rejected = not a fit."
11318
+ enum: ["register", "update", "list"],
11319
+ description: "register = upsert a site; update = patch a site by id; list = list sites."
11204
11320
  },
11205
- limit: { type: "integer", description: "Max rows (default 20, max 100)." },
11206
- offset: { type: "integer", description: "Pagination offset." }
11207
- }
11208
- }
11209
- },
11210
- {
11211
- name: "set_candidate_status",
11212
- description: "Update a candidate's status (2026-DASMG-041). Set status=used to mark the content as used on the site (stamps usedAt and records usedCorpusItemId / usedUrl), or rejected when it is not a fit.",
11213
- inputSchema: {
11214
- type: "object",
11215
- properties: {
11216
- id: { type: "string", description: "content_site_candidates.id (uuid)." },
11217
- status: {
11321
+ id: { type: "string", description: "action=update: managed_sites.id (uuid, required)." },
11322
+ projectKey: {
11218
11323
  type: "string",
11219
- enum: ["candidate", "used", "rejected"],
11220
- description: "New status for the candidate."
11324
+ description: "action=register (required): project identifier. action=list: filter."
11221
11325
  },
11222
- usedCorpusItemId: {
11326
+ domain: {
11223
11327
  type: "string",
11224
- description: "content_corpus_items.id of the page produced (when used)."
11328
+ description: "action=register (required): site domain, e.g. example.nl. action=list: filter."
11225
11329
  },
11226
- usedUrl: { type: "string", description: "URL of the page produced (when used)." },
11227
- reason: { type: "string", description: "Optional note for the status change." }
11228
- },
11229
- required: ["id", "status"]
11230
- }
11231
- },
11232
- {
11233
- name: "register_managed_site",
11234
- description: "Register (or upsert by projectKey+domain) a site for content sync (2026-DASMG-042): domain, optional repo/branch + sitemapUrl, syncMode and a git pathUrlMap. Optional + additive \u2014 external/unmanaged sites still work via extract_article without this. Enables git-push/cron/manual ingestion that re-indexes only CHANGED pages into the corpus (hash-skips unchanged ones).",
11235
- inputSchema: {
11236
- type: "object",
11237
- properties: {
11238
- projectKey: { type: "string", description: "Project identifier (required)." },
11239
- domain: { type: "string", description: "Site domain, e.g. example.nl (required)." },
11240
- siteKey: { type: "string", description: "Optional site identifier within the project." },
11330
+ siteKey: { type: "string", description: "Site identifier within the project." },
11241
11331
  name: { type: "string", description: "Human-friendly site name." },
11242
- repo: { type: "string", description: "GitHub repo owner/repo that publishes the site (for git pushes)." },
11332
+ repo: {
11333
+ type: "string",
11334
+ description: "GitHub repo owner/repo that publishes the site (for git pushes). action=list: filter."
11335
+ },
11243
11336
  branch: { type: "string", description: "Branch whose pushes trigger a sync; omit for any branch." },
11244
11337
  sitemapUrl: { type: "string", description: "Sitemap URL for sitemap/hybrid detection." },
11245
11338
  sourceType: {
@@ -11256,11 +11349,12 @@ var TOOLS = [
11256
11349
  ],
11257
11350
  description: "Provenance label for produced corpus items (default own_site)."
11258
11351
  },
11259
- enabled: { type: "boolean", description: "Whether syncs run (default true)." },
11352
+ enabled: { type: "boolean", description: "register/update: whether syncs run (default true)." },
11353
+ enabledOnly: { type: "boolean", description: "action=list: only enabled sites." },
11260
11354
  syncMode: {
11261
11355
  type: "string",
11262
11356
  enum: ["sitemap", "git", "hybrid", "manual"],
11263
- description: "Change detection: sitemap (lastmod/hash), git (changed paths via pathUrlMap), hybrid (both), manual (only on trigger_content_sync)."
11357
+ description: "Change detection: sitemap (lastmod/hash), git (changed paths via pathUrlMap), hybrid (both), manual (only via content-sync trigger)."
11264
11358
  },
11265
11359
  pathUrlMap: {
11266
11360
  type: "array",
@@ -11272,106 +11366,49 @@ var TOOLS = [
11272
11366
  type: "boolean",
11273
11367
  description: "Flag changed pages for an MG SEO CLI follow-up (soft handoff)."
11274
11368
  },
11369
+ limit: { type: "integer", description: "action=list: max rows (default 50, max 200)." },
11370
+ offset: { type: "integer", description: "action=list: pagination offset." },
11275
11371
  metadata: { type: "object" }
11276
11372
  },
11277
- required: ["projectKey", "domain"]
11373
+ required: ["action"]
11278
11374
  }
11279
11375
  },
11376
+ // ----- Content ingestion runs (2026-DASMG-042), consolidated -----
11280
11377
  {
11281
- name: "update_managed_site",
11282
- description: "Update an existing managed site by id (2026-DASMG-042). Only the provided fields change \u2014 enable/disable, switch syncMode, set sitemapUrl/pathUrlMap, etc.",
11378
+ name: "content-sync",
11379
+ description: 'Content ingestion runs for a managed site (2026-DASMG-042). Pick the operation with `action`:\n- "trigger": start a run by siteId, or projectKey+domain. Detects changed pages (sitemap/git/hash), re-indexes only changes into the corpus and snapshots the previous version. Returns the runId; poll with action=status.\n- "status": read run status by runId (that run), or by siteId / projectKey+domain (recent runs). Returns changed/skipped/failed counts, the changed URLs, limitations and the site summary.',
11283
11380
  inputSchema: {
11284
11381
  type: "object",
11285
11382
  properties: {
11286
- id: { type: "string", description: "managed_sites.id (uuid, required)." },
11287
- name: { type: "string", description: "Human-friendly site name." },
11288
- domain: { type: "string", description: "Site domain." },
11289
- siteKey: { type: "string", description: "Site identifier within the project." },
11290
- repo: { type: "string", description: "GitHub repo owner/repo." },
11291
- branch: { type: "string", description: "Branch whose pushes trigger a sync." },
11292
- sitemapUrl: { type: "string", description: "Sitemap URL." },
11293
- sourceType: {
11294
- type: "string",
11295
- enum: [
11296
- "own_site",
11297
- "client_site",
11298
- "external_source",
11299
- "competitor",
11300
- "news",
11301
- "government",
11302
- "supplier",
11303
- "unknown"
11304
- ]
11305
- },
11306
- enabled: { type: "boolean", description: "Enable/disable syncs." },
11307
- syncMode: {
11383
+ action: {
11308
11384
  type: "string",
11309
- enum: ["sitemap", "git", "hybrid", "manual"]
11385
+ enum: ["trigger", "status"],
11386
+ description: "trigger = start an ingestion run; status = read run status."
11310
11387
  },
11311
- pathUrlMap: { type: "array", items: { type: "object" } },
11312
- maxUrlsPerRun: { type: "integer", description: "Per-run URL cap." },
11313
- exportToSeoCli: { type: "boolean" },
11314
- metadata: { type: "object" }
11315
- },
11316
- required: ["id"]
11317
- }
11318
- },
11319
- {
11320
- name: "list_managed_sites",
11321
- description: "List managed sites (2026-DASMG-042), filter by projectKey/domain/repo/enabledOnly. Compact, paginated.",
11322
- inputSchema: {
11323
- type: "object",
11324
- properties: {
11325
- projectKey: { type: "string", description: "Project filter." },
11326
- domain: { type: "string", description: "Domain filter." },
11327
- repo: { type: "string", description: "Repo filter (owner/repo)." },
11328
- enabledOnly: { type: "boolean", description: "Only enabled sites." },
11329
- limit: { type: "integer", description: "Max rows (default 50, max 200)." },
11330
- offset: { type: "integer", description: "Pagination offset." }
11331
- }
11332
- }
11333
- },
11334
- {
11335
- name: "trigger_content_sync",
11336
- description: "Start a content ingestion run for a managed site (2026-DASMG-042) by siteId, or projectKey+domain. Detects changed pages (sitemap/git/hash), re-indexes only changes into the corpus and snapshots the previous version. Returns the runId; poll get_content_sync_status for results.",
11337
- inputSchema: {
11338
- type: "object",
11339
- properties: {
11340
11388
  siteId: { type: "string", description: "managed_sites.id (uuid)." },
11341
11389
  projectKey: { type: "string", description: "Project identifier (with domain)." },
11342
11390
  domain: { type: "string", description: "Site domain (with projectKey)." },
11391
+ runId: { type: "string", description: "action=status: content_ingestion_runs.id (uuid)." },
11343
11392
  triggerType: {
11344
11393
  type: "string",
11345
11394
  enum: ["manual", "git_push", "deploy", "cron", "sitemap"],
11346
- description: "Trigger label recorded on the run (default manual)."
11395
+ description: "action=trigger: label recorded on the run (default manual)."
11347
11396
  },
11348
- sourceCommit: { type: "string", description: "Source git commit/deploy id, when known." },
11349
- sourceRef: { type: "string", description: "Source git ref, when known." },
11397
+ sourceCommit: { type: "string", description: "action=trigger: source git commit/deploy id, when known." },
11398
+ sourceRef: { type: "string", description: "action=trigger: source git ref, when known." },
11350
11399
  urls: {
11351
11400
  type: "array",
11352
11401
  items: { type: "string" },
11353
- description: "Restrict the run to these URLs (skips detection)."
11402
+ description: "action=trigger: restrict the run to these URLs (skips detection)."
11354
11403
  },
11355
11404
  changedPaths: {
11356
11405
  type: "array",
11357
11406
  items: { type: "string" },
11358
- description: "Changed repo paths to map to URLs via pathUrlMap."
11359
- }
11360
- }
11361
- }
11362
- },
11363
- {
11364
- name: "get_content_sync_status",
11365
- description: "Read ingestion run status (2026-DASMG-042): by runId (that run), or by siteId / projectKey+domain (recent runs). Returns changed/skipped/failed counts, the changed URLs, limitations and the site summary.",
11366
- inputSchema: {
11367
- type: "object",
11368
- properties: {
11369
- runId: { type: "string", description: "content_ingestion_runs.id (uuid)." },
11370
- siteId: { type: "string", description: "managed_sites.id (uuid)." },
11371
- projectKey: { type: "string", description: "Project identifier (with domain)." },
11372
- domain: { type: "string", description: "Site domain (with projectKey)." },
11373
- limit: { type: "integer", description: "Max recent runs to return (default 10)." }
11374
- }
11407
+ description: "action=trigger: changed repo paths to map to URLs via pathUrlMap."
11408
+ },
11409
+ limit: { type: "integer", description: "action=status: max recent runs to return (default 10)." }
11410
+ },
11411
+ required: ["action"]
11375
11412
  }
11376
11413
  },
11377
11414
  {
@@ -11478,8 +11515,10 @@ var MCP_VERSION = "7.3.0";
11478
11515
  async function handleListTools() {
11479
11516
  if (!authContext) return { tools: TOOLS };
11480
11517
  const allowedTools = authContext.allowedTools;
11518
+ const isSuperadmin = authContext.roleName === "superadmin";
11481
11519
  const accessible = TOOLS.filter((tool) => {
11482
11520
  if (allowedTools && !allowedTools.includes(tool.name)) return false;
11521
+ if (SUPERADMIN_ONLY_TOOLS.has(tool.name) && !isSuperadmin) return false;
11483
11522
  const requiredModule = TOOL_MODULE_MAP[tool.name];
11484
11523
  if (!requiredModule) return true;
11485
11524
  return authContext.permissions.modules[requiredModule] === true;
@@ -11502,6 +11541,16 @@ async function handleCallTool(request) {
11502
11541
  ]
11503
11542
  };
11504
11543
  }
11544
+ if (SUPERADMIN_ONLY_TOOLS.has(name) && authContext.roleName !== "superadmin") {
11545
+ return {
11546
+ content: [
11547
+ {
11548
+ type: "text",
11549
+ text: `Access denied: "${name}" is restricted to superadmins (it manages production secrets).`
11550
+ }
11551
+ ]
11552
+ };
11553
+ }
11505
11554
  const requiredModule = TOOL_MODULE_MAP[name];
11506
11555
  if (requiredModule && authContext.permissions.modules[requiredModule] !== true) {
11507
11556
  return {
@@ -11637,106 +11686,31 @@ async function executeToolCall(name, a, _serverId) {
11637
11686
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
11638
11687
  };
11639
11688
  }
11640
- case "research_topic": {
11641
- const urls = Array.isArray(a.urls) ? a.urls.filter((u) => typeof u === "string") : void 0;
11642
- const topic = typeof a.topic === "string" ? a.topic.trim() : void 0;
11643
- if ((!urls || urls.length === 0) && !topic) {
11644
- return {
11645
- content: [
11646
- { type: "text", text: "Error: provide `urls` (curated) and/or a `topic`" }
11647
- ]
11648
- };
11649
- }
11650
- const res = await fetch(`${dashboardBaseUrl}/api/tools/research-topic`, {
11651
- method: "POST",
11652
- headers: {
11653
- "content-type": "application/json",
11654
- authorization: `Bearer ${apiKey}`
11655
- },
11656
- body: JSON.stringify({
11657
- topic,
11658
- urls,
11659
- discover: a.discover && typeof a.discover === "object" ? a.discover : void 0,
11660
- includeRawText: typeof a.includeRawText === "boolean" ? a.includeRawText : void 0,
11661
- maxRawTextChars: typeof a.maxRawTextChars === "number" ? a.maxRawTextChars : void 0
11662
- })
11663
- });
11664
- if (!res.ok) {
11665
- const detail = await res.text().catch(() => "");
11666
- return {
11667
- content: [
11668
- {
11669
- type: "text",
11670
- text: `Error: research_topic failed (${res.status}). ${detail.slice(0, 300)}`
11671
- }
11672
- ]
11673
- };
11674
- }
11675
- const data = await res.json();
11676
- return {
11677
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
11678
- };
11679
- }
11680
- // ----- Content registry / corpus persistence (2026-DASMG-041) -----
11681
- // All content-registry tools proxy to one dispatcher route; the tool name
11682
- // is the dispatcher `action` and the args are forwarded verbatim as
11683
- // `params` (the route validates them with the matching Zod schema).
11684
- case "save_content_source":
11685
- case "get_content_source_by_url":
11686
- case "get_content_source_by_id":
11687
- case "search_content_sources":
11688
- case "save_research_pack":
11689
- case "get_research_pack":
11690
- case "save_content_corpus_item":
11691
- case "list_project_content_corpus":
11692
- case "get_content_context_for_project":
11689
+ // ----- Content family (2026-DASMG-040/041/042), consolidated -----
11690
+ // Action-based tools + the two singletons, mapped to the backend
11691
+ // content-registry / managed-site / research-topic dispatchers by
11692
+ // resolveContentDispatch (the backend still speaks the granular action
11693
+ // names, so no server change is needed).
11694
+ case "research":
11695
+ case "content-source":
11696
+ case "content-corpus":
11697
+ case "content-snapshot":
11698
+ case "site-candidate":
11699
+ case "managed-site":
11700
+ case "content-sync":
11693
11701
  case "save_content_quality_run":
11694
- case "list_content_snapshots":
11695
- case "link_content_pack_to_ticket":
11696
- case "prune_content_snapshots":
11697
- case "add_site_candidates":
11698
- case "list_site_candidates":
11699
- case "set_candidate_status": {
11700
- const res = await fetch(`${dashboardBaseUrl}/api/tools/content-registry`, {
11701
- method: "POST",
11702
- headers: {
11703
- "content-type": "application/json",
11704
- authorization: `Bearer ${apiKey}`
11705
- },
11706
- body: JSON.stringify({ action: name, params: a })
11707
- });
11708
- if (!res.ok) {
11709
- const detail = await res.text().catch(() => "");
11710
- return {
11711
- content: [
11712
- {
11713
- type: "text",
11714
- text: `Error: ${name} failed (${res.status}). ${detail.slice(0, 300)}`
11715
- }
11716
- ]
11717
- };
11702
+ case "link_content_pack_to_ticket": {
11703
+ const dispatch = resolveContentDispatch(name, a);
11704
+ if ("error" in dispatch) {
11705
+ return { content: [{ type: "text", text: `Error: ${dispatch.error}` }] };
11718
11706
  }
11719
- const data = await res.json();
11720
- return {
11721
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
11722
- };
11723
- }
11724
- // ----- Managed site content sync (2026-DASMG-042) -----
11725
- // The 5 PUBLIC managed-site tools proxy to one dispatcher route; the tool
11726
- // name is the dispatcher `action`, args forwarded verbatim as `params`
11727
- // (the route validates them and only allows the public actions).
11728
- case "register_managed_site":
11729
- case "update_managed_site":
11730
- case "list_managed_sites":
11731
- case "trigger_content_sync":
11732
- case "get_content_sync_status": {
11733
- const res = await fetch(`${dashboardBaseUrl}/api/tools/managed-site`, {
11707
+ const res = await fetch(`${dashboardBaseUrl}/api/tools/${dispatch.route}`, {
11734
11708
  method: "POST",
11735
11709
  headers: {
11736
11710
  "content-type": "application/json",
11737
11711
  authorization: `Bearer ${apiKey}`
11738
11712
  },
11739
- body: JSON.stringify({ action: name, params: a })
11713
+ body: JSON.stringify(dispatch.body)
11740
11714
  });
11741
11715
  if (!res.ok) {
11742
11716
  const detail = await res.text().catch(() => "");