drupal-mcp-connector 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.0] - 2026-06-29
11
+
12
+ ### Added
13
+ - **Audit command suite — 22 new read-only audit tools across four groups**, expanding
14
+ the connector from content reporting into link/404 integrity and configuration
15
+ posture. All follow the existing `drupal_report_*` / `drupal_audit_*` convention
16
+ (auto-classified read-only), degrade with a `gatedReport`/`gated` payload when a
17
+ required source is absent, and flag `approximate`/`truncated` when sampling-bounded.
18
+ - **Links & 404 integrity** (`reports-links.js`): `drupal_report_404_log`,
19
+ `drupal_report_redirect_health`, `drupal_report_broken_links`,
20
+ `drupal_report_alias_coverage`, `drupal_report_menu_integrity`,
21
+ `drupal_report_broken_embeds`.
22
+ - **Config & site-health** (`reports-config.js`): `drupal_report_config_drift`,
23
+ `drupal_audit_config_best_practices`, `drupal_report_module_audit`,
24
+ `drupal_report_permission_audit`, `drupal_report_status_report`,
25
+ `drupal_report_text_format_audit`, `drupal_report_cache_config`.
26
+ - **Content quality & governance** (`reports-content.js`):
27
+ `drupal_report_duplicate_content`, `drupal_report_workflow_bottlenecks`,
28
+ `drupal_report_translation_coverage`, `drupal_report_scheduled_content`,
29
+ `drupal_report_readability`, `drupal_report_orphan_pages`,
30
+ `drupal_report_pii_exposure`, `drupal_report_seo_meta_coverage`.
31
+ - **Composite** (`audit-composite.js`): `drupal_audit_site_health` — a scored
32
+ dashboard that runs a configurable battery of the above and rolls them into one
33
+ letter grade, with each section degrading independently.
34
+ - **`drupal-full-audit` MCP prompt** — walks a client through running the composite
35
+ audit and turning the dashboard into a prioritized action plan.
36
+ - **Opt-in live link checking.** `drupal_report_broken_links` performs no network
37
+ egress by default; with `checkLive: true` it verifies links via a bounded,
38
+ SSRF-guarded checker (`src/lib/link-checker.js`) that refuses
39
+ loopback/private/link-local/metadata addresses, requires a host allowlist for
40
+ external hosts, and caps concurrency, timeout, and link count. Configurable per site
41
+ via an optional `audit` block (`linkCheckAllowedHosts`, `linkCheckConcurrency`,
42
+ `linkCheckTimeoutMs`, `linkCheckMaxLinks`).
43
+ - **Self-sufficient privileged audits.** Log/config/module/permission/requirements
44
+ audits read their data through the connector's own **drush bridge** (`watchdog:show`,
45
+ `config:status`/`config:get`, `pm:list`/`pm:security`, `role:list`,
46
+ `core:requirements`, and a read-only `sql:query` to enumerate `filter.format.*`), so
47
+ they work against stock Drupal with **no companion module required**. The
48
+ config-inspection audits additionally prefer the existing governed config server-tool
49
+ when a site has `serverTools` configured. Each returns a `gated`/`unavailable` payload
50
+ (never throws) when no source is configured.
51
+
52
+ ### Changed
53
+ - `sshDrush` and `parseDrush` are now exported from `src/tools/drush.js`, and a
54
+ `toolResultData` helper is exported from `src/lib/server-tools.js`, so the audit tool
55
+ groups can reuse the hardened drush bridge and the existing governed config transport.
56
+
57
+ ### Security
58
+ - The drush bridge no longer logs secret-bearing flag values (`--password`/`--token`/
59
+ `--secret`/`--api-key`) in clear text — they are redacted to `***` in the operational
60
+ stderr log line (`redactSecretArgs`). Clears a `js/clear-text-logging` finding.
61
+
10
62
  ## [1.5.1] - 2026-06-29
11
63
 
12
64
  ### Fixed
package/README.md CHANGED
@@ -51,7 +51,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
51
51
 
52
52
  ## Features
53
53
 
54
- ### 93 Tools Across 21 Modules
54
+ ### 119 Tools Across 26 Modules
55
55
 
56
56
  | Module | Tools |
57
57
  |--------|-------|
@@ -76,6 +76,10 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
76
76
  | **Redirects** | Create active URL redirects (301/302) + update/repoint existing redirects (Redirect module) |
77
77
  | **Search** | Best-effort content search (title match; Search API/Solr-ready) |
78
78
  | **Reports (extra)** | Orphaned references, unpublished content, missing-field audits |
79
+ | **Reports — Links & 404** | 404-log → redirect candidates, redirect-table health (chains/loops/duplicates), body-link inventory with opt-in live checking, URL-alias coverage, menu-link integrity, embedded-entity scan |
80
+ | **Reports — Config & Health** | Config drift, best-practice/security config linter, module audit (dev/debug + security updates), permission audit, Drupal status report, text-format safety, cache posture |
81
+ | **Reports — Content Quality** | Duplicate content, workflow bottlenecks, translation coverage, scheduled content, readability (Flesch), orphan pages, PII exposure (masked), structured-meta SEO coverage |
82
+ | **Audit (composite)** | `drupal_audit_site_health` — scored content/links/config dashboard with a roll-up grade |
79
83
  | **Config & Governance** | Governed config get/list/set via the server-tool bridge; `drupal_mcp_whoami` tier/capability report |
80
84
 
81
85
  **Preview writes with `dryRun`.** The node and entity create/update/delete tools accept an optional `dryRun: true` flag that validates the request and returns a preview of exactly what would be written — without committing anything to Drupal.
@@ -89,6 +93,7 @@ Browsable, always-fresh context the client can read without calling a tool:
89
93
  ### MCP Prompts
90
94
  Workflow templates usable as slash-commands from any MCP client:
91
95
  - `drupal-content-audit` — walk through a full site content audit
96
+ - `drupal-full-audit` — run the composite content/links/config audit and turn the scored dashboard into a prioritized action plan
92
97
  - `drupal-create-article` — guided article creation with all fields
93
98
  - `drupal-seo-fix` — find and fix SEO gaps
94
99
  - `drupal-user-cleanup` — identify and handle inactive accounts
@@ -189,7 +194,7 @@ Governance keys off the authenticated account's role and OAuth scopes — not re
189
194
  | [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
190
195
  | [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
191
196
  | [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
192
- | [Tools Reference](docs/tools-reference.md) | Full reference for all 93 tools |
197
+ | [Tools Reference](docs/tools-reference.md) | Full reference for all 119 tools |
193
198
  | [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
194
199
  | [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
195
200
  | [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
@@ -78,6 +78,13 @@
78
78
  "port": 22,
79
79
  "allowedCommands": ["config:export", "config:status"]
80
80
  },
81
+ "audit": {
82
+ "_comment": "Optional. Controls drupal_report_broken_links live checking (off unless checkLive:true is passed). linkCheckAllowedHosts gates external hosts; same-origin links are always allowed when checking live.",
83
+ "linkCheckAllowedHosts": ["www.drupal.org", "wilkesliberty.com"],
84
+ "linkCheckConcurrency": 5,
85
+ "linkCheckTimeoutMs": 5000,
86
+ "linkCheckMaxLinks": 200
87
+ },
81
88
  "security": { "preset": "config-editor" }
82
89
  },
83
90
 
@@ -108,7 +115,11 @@
108
115
  },
109
116
 
110
117
  "_server_tools": {
111
- "_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. Authenticated with the same OAuth bearer."
118
+ "_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. The config-inspection audits will also use it (governed path) when present, but fall back to the drush bridge, so it is optional for the audits. Authenticated with the same OAuth bearer."
119
+ },
120
+
121
+ "_audit_tools": {
122
+ "_comment": "The audit tool groups (drupal_report_* links/config/content, drupal_audit_site_health) are read-only. Privileged audits (404 log, config drift/best-practices, module/permission/status) are self-sufficient via the connector's own drush bridge (drushSsh) — no companion module required; the config-inspection audits additionally prefer the governed config server-tool when serverTools is configured. They report 'unavailable' when no source is configured for the site. drupal_report_broken_links does no outbound HTTP unless called with checkLive:true; see each site's optional 'audit' block for live-check limits."
112
123
  },
113
124
 
114
125
  "_mcp_client_registration": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -68,6 +68,10 @@ import * as structure from "./tools/structure.js";
68
68
  import * as redirects from "./tools/redirects.js";
69
69
  import * as search from "./tools/search.js";
70
70
  import * as reportsExtra from "./tools/reports-extra.js";
71
+ import * as reportsLinks from "./tools/reports-links.js";
72
+ import * as reportsConfig from "./tools/reports-config.js";
73
+ import * as reportsContent from "./tools/reports-content.js";
74
+ import * as auditComposite from "./tools/audit-composite.js";
71
75
  import * as config from "./tools/config.js";
72
76
 
73
77
  // ---------------------------------------------------------------------------
@@ -75,7 +79,8 @@ import * as config from "./tools/config.js";
75
79
  // ---------------------------------------------------------------------------
76
80
 
77
81
  const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
78
- revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra, config];
82
+ revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
83
+ reportsLinks, reportsConfig, reportsContent, auditComposite, config];
79
84
 
80
85
  // Flatten every module's tool definitions into one ListTools payload, and merge
81
86
  // their handler maps into a single closed dispatch table keyed by tool name.
@@ -247,6 +252,14 @@ const PROMPTS = [
247
252
  description: "Identify inactive, never-logged-in, or overly permissioned user accounts and take action.",
248
253
  arguments: [{ name: "site", description: "Target site", required: false }],
249
254
  },
255
+ {
256
+ name: "drupal-full-audit",
257
+ description: "Run a full site-health audit — content, link/404 integrity, and configuration posture — and turn the scored dashboard into a prioritized action plan.",
258
+ arguments: [
259
+ { name: "site", description: "Named site to audit (omit for default)", required: false },
260
+ { name: "type", description: "Primary content type to audit", required: false },
261
+ ],
262
+ },
250
263
  ];
251
264
 
252
265
  /**
@@ -310,6 +323,19 @@ function getPromptMessages(name, args) {
310
323
  "6. Ask for approval before making any changes."
311
324
  }},
312
325
  ],
326
+ "drupal-full-audit": [
327
+ { role: "user", content: { type: "text", text:
328
+ `Please run a full site-health audit ${site} and turn it into a prioritized action plan.\n\n` +
329
+ `1. Call drupal_audit_site_health (type: "${type}") for the scored dashboard and overall grade.\n` +
330
+ "2. For any section reporting high-severity findings, drill in with the matching tool for detail:\n" +
331
+ " - links/404: drupal_report_404_log, drupal_report_redirect_health, drupal_report_broken_links (checkLive only with approval).\n" +
332
+ " - config: drupal_audit_config_best_practices, drupal_report_module_audit, drupal_report_permission_audit.\n" +
333
+ " - content: drupal_report_pii_exposure, drupal_report_duplicate_content, drupal_report_readability.\n" +
334
+ "3. For sections reported 'unavailable', note what (server-tool bridge or drush) would enable them — do not treat unavailable as 'passing'.\n" +
335
+ "4. Synthesize a prioritized plan: (a) high-severity/security fixes first, (b) content-quality improvements, (c) process recommendations.\n" +
336
+ "5. Present counts, severity, and specific node/config references; propose redirects for the top 404s. Ask before making any changes."
337
+ }},
338
+ ],
313
339
  };
314
340
 
315
341
  return new Map(Object.entries(prompts)).get(name) ?? [{ role: "user", content: { type: "text", text: `Run the ${name} workflow ${site}.` } }];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Privileged-source resolution for the audit tool groups.
3
+ *
4
+ * Single responsibility: run an audit that needs privileged log/config/module
5
+ * data through the best available source, in priority order — an optional
6
+ * governed server-tool first (when a callback is supplied), the connector's own
7
+ * drush SSH bridge otherwise — and report a clean "unavailable" outcome (never
8
+ * throw) when no source is configured or all fail. The drush bridge makes the
9
+ * audits self-sufficient against stock Drupal; no companion module is required.
10
+ */
11
+
12
+ /**
13
+ * Whether the governed server-tool bridge is configured for a site.
14
+ * @param {object} site Resolved site config.
15
+ * @returns {boolean}
16
+ */
17
+ export function serverToolsConfigured(site) {
18
+ return Boolean(site?.serverTools?.url);
19
+ }
20
+
21
+ /**
22
+ * Whether the drush SSH bridge is configured for a site.
23
+ * @param {object} site Resolved site config.
24
+ * @returns {boolean}
25
+ */
26
+ export function drushConfigured(site) {
27
+ return Boolean(site?.drushSsh);
28
+ }
29
+
30
+ /**
31
+ * Run a privileged audit through the first source that succeeds.
32
+ *
33
+ * Tries the optional governed server-tool (when a `serverTool` callback is given
34
+ * and `serverTools` is configured), then the connector's own drush bridge (when a
35
+ * `drush` callback is given and `drushSsh` is configured). Either callback may be
36
+ * omitted when that source can't serve the audit. Failures are accumulated, not
37
+ * thrown, so the caller can surface them in a gated payload.
38
+ *
39
+ * @param {object} site Resolved site config.
40
+ * @param {object} sources
41
+ * @param {(() => Promise<*>)} [sources.serverTool] Server-tool attempt.
42
+ * @param {(() => Promise<*>)} [sources.drush] Drush attempt.
43
+ * @returns {Promise<{source: ("server-tool"|"drush"|null), data?: *, attempts: string[]}>}
44
+ * On success, `source` names the winning path and `data` is its result. On
45
+ * failure, `source` is null and `attempts` explains why each path was skipped
46
+ * or failed.
47
+ */
48
+ export async function runPrivileged(site, { serverTool, drush } = {}) {
49
+ const attempts = [];
50
+
51
+ if (serverTool && serverToolsConfigured(site)) {
52
+ try { return { source: "server-tool", data: await serverTool(), attempts }; }
53
+ catch (err) { attempts.push(`server-tool: ${err?.message || err}`); }
54
+ } else if (serverTool) {
55
+ attempts.push("server-tool: serverTools.url not configured for this site");
56
+ }
57
+
58
+ if (drush && drushConfigured(site)) {
59
+ try { return { source: "drush", data: await drush(), attempts }; }
60
+ catch (err) { attempts.push(`drush: ${err?.message || err}`); }
61
+ } else if (drush) {
62
+ attempts.push("drush: drushSsh not configured for this site");
63
+ }
64
+
65
+ return { source: null, attempts };
66
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Shared helpers for the audit tool groups — HTML link/embed extraction and
3
+ * link classification, backend-neutral.
4
+ *
5
+ * Single responsibility: turn a node's rendered body HTML into structured link
6
+ * and embed records, and classify a URL as internal/external/other relative to
7
+ * a site's base URL. The link (`reports-links.js`) and content
8
+ * (`reports-content.js`) audits share this one parser so link handling stays
9
+ * consistent across the suite.
10
+ *
11
+ * Detection is regex-based on the stored body markup — a heuristic pass, not a
12
+ * full DOM parse — matching the existing seo/accessibility audits in reports.js.
13
+ */
14
+
15
+ import { fieldValue } from "./reports-support.js";
16
+
17
+ /**
18
+ * Read a node's body HTML from a canonical entity. Mirrors the body-extraction
19
+ * idiom in reports.js (the body field may be a `{value}` object or a scalar).
20
+ * @param {object} entity Canonical entity.
21
+ * @param {string[]} [candidates] Field names to try, in order.
22
+ * @returns {string} The body HTML, or "" when absent.
23
+ */
24
+ export function bodyHtml(entity, candidates = ["body"]) {
25
+ const raw = fieldValue(entity, candidates);
26
+ const value = raw && typeof raw === "object" ? raw.value : raw;
27
+ return typeof value === "string" ? value : "";
28
+ }
29
+
30
+ /**
31
+ * Extract all `href` values from `<a>` tags in an HTML string.
32
+ * @param {string} html Body markup.
33
+ * @returns {string[]} Raw href values in document order (may include dups).
34
+ */
35
+ export function extractAnchors(html) {
36
+ return matchAttr(html, /<a\b[^>]*?\bhref\s*=\s*["']([^"']+)["']/gi);
37
+ }
38
+
39
+ /**
40
+ * Extract all `src` values from `<img>` tags in an HTML string.
41
+ * @param {string} html Body markup.
42
+ * @returns {string[]} Raw src values in document order (may include dups).
43
+ */
44
+ export function extractImages(html) {
45
+ return matchAttr(html, /<img\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/gi);
46
+ }
47
+
48
+ /**
49
+ * Extract entity embeds from body markup. Covers the CKEditor media/entity embed
50
+ * shapes Drupal emits — `<drupal-media>`, `<drupal-entity>`, and any element
51
+ * carrying `data-entity-type` + `data-entity-uuid` (entity_embed). Each embed is
52
+ * returned as a `{ entityType, uuid }` ref so callers can probe the target.
53
+ * @param {string} html Body markup.
54
+ * @returns {Array<{entityType: ?string, uuid: string}>} De-duped embed refs.
55
+ */
56
+ export function extractEmbeds(html) {
57
+ const out = [];
58
+ const seen = new Set();
59
+ // Match any tag that carries a data-entity-uuid attribute; pull the optional
60
+ // sibling data-entity-type from the same tag.
61
+ const tagRe = /<([a-z-]+)\b([^>]*\bdata-entity-uuid\s*=\s*["'][^"']+["'][^>]*)>/gi;
62
+ for (const m of String(html || "").matchAll(tagRe)) {
63
+ const attrs = m[2];
64
+ const uuid = readAttr(attrs, /\bdata-entity-uuid\s*=\s*["']([^"']+)["']/i);
65
+ if (!uuid || seen.has(uuid)) continue;
66
+ seen.add(uuid);
67
+ out.push({ entityType: readAttr(attrs, /\bdata-entity-type\s*=\s*["']([^"']+)["']/i), uuid });
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Classify a URL relative to a site base URL.
74
+ *
75
+ * @param {string} url Raw href/src value.
76
+ * @param {string} baseUrl The site's configured base URL (origin).
77
+ * @returns {{kind: "internal"|"external"|"fragment"|"mailto"|"tel"|"other",
78
+ * url: string, path: ?string, host: ?string}} Classification. `path` is set
79
+ * for internal links (origin-relative, no query/fragment); `host` for external.
80
+ */
81
+ export function classifyLink(url, baseUrl) {
82
+ const raw = (url || "").trim();
83
+ if (!raw) return { kind: "other", url: raw, path: null, host: null };
84
+ if (raw.startsWith("#")) return { kind: "fragment", url: raw, path: null, host: null };
85
+
86
+ const lower = raw.toLowerCase();
87
+ if (lower.startsWith("mailto:")) return { kind: "mailto", url: raw, path: null, host: null };
88
+ if (lower.startsWith("tel:")) return { kind: "tel", url: raw, path: null, host: null };
89
+ if (/^(javascript|data):/i.test(lower)) return { kind: "other", url: raw, path: null, host: null };
90
+
91
+ const baseHost = hostOf(baseUrl);
92
+
93
+ // Root- or path-relative internal links.
94
+ if (raw.startsWith("/") && !raw.startsWith("//")) {
95
+ return { kind: "internal", url: raw, path: normalizePath(raw), host: baseHost };
96
+ }
97
+
98
+ // Protocol-relative or absolute URLs.
99
+ let parsed;
100
+ try {
101
+ parsed = new URL(raw, baseUrl || undefined);
102
+ } catch {
103
+ return { kind: "other", url: raw, path: null, host: null };
104
+ }
105
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
106
+ return { kind: "other", url: raw, path: null, host: null };
107
+ }
108
+ if (baseHost && parsed.host.toLowerCase() === baseHost.toLowerCase()) {
109
+ return { kind: "internal", url: raw, path: normalizePath(parsed.pathname), host: parsed.host };
110
+ }
111
+ return { kind: "external", url: raw, path: null, host: parsed.host };
112
+ }
113
+
114
+ /**
115
+ * Normalize an internal path for comparison: decode, strip query/fragment, drop
116
+ * a trailing slash (except root), and ensure a leading slash.
117
+ * @param {string} path A pathname or root-relative URL.
118
+ * @returns {string} Normalized path beginning with "/".
119
+ */
120
+ export function normalizePath(path) {
121
+ let p = String(path || "/");
122
+ const fragIdx = p.indexOf("#");
123
+ if (fragIdx !== -1) p = p.slice(0, fragIdx);
124
+ const queryIdx = p.indexOf("?");
125
+ if (queryIdx !== -1) p = p.slice(0, queryIdx);
126
+ try { p = decodeURI(p); } catch { /* leave as-is on malformed escapes */ }
127
+ if (!p.startsWith("/")) p = `/${p}`;
128
+ if (p.length > 1 && p.endsWith("/")) p = p.replace(/\/+$/, "");
129
+ return p || "/";
130
+ }
131
+
132
+ /**
133
+ * Pull the host (`host:port`) out of a base URL string, tolerating a bare host.
134
+ * @param {string} baseUrl A configured site base URL.
135
+ * @returns {?string} The host, or null when unparseable/absent.
136
+ */
137
+ export function hostOf(baseUrl) {
138
+ if (!baseUrl) return null;
139
+ try { return new URL(baseUrl).host; }
140
+ catch {
141
+ try { return new URL(`https://${baseUrl}`).host; }
142
+ catch { return null; }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Run a global attribute-capturing regex over HTML and return capture group 1
148
+ * for every match.
149
+ * @param {string} html Source markup.
150
+ * @param {RegExp} re Global regex whose first group is the attribute value.
151
+ * @returns {string[]} Captured values in document order.
152
+ */
153
+ function matchAttr(html, re) {
154
+ const out = [];
155
+ if (!html) return out;
156
+ for (const m of String(html).matchAll(re)) out.push(m[1]);
157
+ return out;
158
+ }
159
+
160
+ /**
161
+ * Read a single attribute value out of a tag's attribute string using a literal
162
+ * capturing regex.
163
+ * @param {string} attrs The raw attribute text inside a tag.
164
+ * @param {RegExp} re A regex whose first group captures the attribute value.
165
+ * @returns {?string} The value, or null when the attribute is absent.
166
+ */
167
+ function readAttr(attrs, re) {
168
+ const m = String(attrs || "").match(re);
169
+ return m ? m[1] : null;
170
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Guarded live link checker — opt-in outbound HTTP for the broken-links audit.
3
+ *
4
+ * Single responsibility: given a set of URLs, perform bounded liveness checks
5
+ * (HEAD, falling back to GET) and report each URL's status. Outbound HTTP is a
6
+ * privileged capability, so this module is the one place network egress is
7
+ * allowed during an audit, and only when the caller explicitly opts in.
8
+ *
9
+ * Safeguards (see checkLinks):
10
+ * - SSRF guard: only http(s); refuse loopback/private/link-local/metadata
11
+ * hosts and IP literals in private ranges.
12
+ * - Allowlist: external hosts are skipped unless listed in
13
+ * `allowedHosts`; same-origin (internal) hosts are always allowed.
14
+ * - Caps: bounded concurrency, per-request timeout, and a hard ceiling on the
15
+ * number of URLs checked (results flag `truncated` when the ceiling is hit).
16
+ */
17
+
18
+ import nodeFetch from "node-fetch";
19
+
20
+ /** Default per-request timeout (ms). */
21
+ export const DEFAULT_TIMEOUT_MS = 5000;
22
+ /** Default number of concurrent in-flight checks. */
23
+ export const DEFAULT_CONCURRENCY = 5;
24
+ /** Default hard ceiling on URLs checked in one call. */
25
+ export const DEFAULT_MAX_LINKS = 200;
26
+
27
+ /**
28
+ * Hostnames that must never be probed regardless of allowlist — loopback and
29
+ * cloud metadata endpoints.
30
+ */
31
+ const BLOCKED_HOSTNAMES = new Set([
32
+ "localhost",
33
+ "metadata.google.internal",
34
+ ]);
35
+
36
+ /**
37
+ * Decide whether a URL is safe to probe: http(s) only, and not pointed at a
38
+ * loopback, private, link-local, unique-local, or metadata address. Hostnames
39
+ * that are not IP literals are allowed past the IP-range check (DNS is not
40
+ * resolved here); the allowlist in checkLinks is the second gate for those.
41
+ *
42
+ * @param {string} url Absolute URL to validate.
43
+ * @returns {{safe: boolean, reason: ?string, host: ?string}} Verdict.
44
+ */
45
+ export function isSafeUrl(url) {
46
+ let parsed;
47
+ try { parsed = new URL(url); }
48
+ catch { return { safe: false, reason: "unparseable URL", host: null }; }
49
+
50
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
51
+ return { safe: false, reason: `unsupported protocol ${parsed.protocol}`, host: parsed.host };
52
+ }
53
+
54
+ // URL.hostname returns IPv6 literals wrapped in brackets; strip them so the
55
+ // range checks below see a bare address.
56
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
57
+ if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
58
+ return { safe: false, reason: "loopback host", host: parsed.host };
59
+ }
60
+ if (isPrivateIp(hostname)) {
61
+ return { safe: false, reason: "private or link-local address", host: parsed.host };
62
+ }
63
+ return { safe: true, reason: null, host: parsed.host };
64
+ }
65
+
66
+ /**
67
+ * Whether a hostname is an IP literal in a loopback/private/link-local/ULA range.
68
+ * Non-IP hostnames return false (resolution happens at fetch time, not here).
69
+ * @param {string} hostname Lowercased hostname from a URL.
70
+ * @returns {boolean} True when the literal IP is in a blocked range.
71
+ */
72
+ function isPrivateIp(hostname) {
73
+ // IPv6 literals arrive bracket-stripped from URL.hostname.
74
+ if (hostname.includes(":")) {
75
+ if (hostname === "::1") return true; // loopback
76
+ if (hostname.startsWith("fe80")) return true; // link-local
77
+ if (hostname.startsWith("fc") || hostname.startsWith("fd")) return true; // ULA
78
+ if (hostname.startsWith("::ffff:")) return isPrivateIp(hostname.slice(7)); // mapped v4
79
+ return false;
80
+ }
81
+ const m = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
82
+ if (!m) return false;
83
+ const [a, b] = [Number(m[1]), Number(m[2])];
84
+ if (a === 127) return true; // 127.0.0.0/8 loopback
85
+ if (a === 10) return true; // 10.0.0.0/8
86
+ if (a === 0) return true; // 0.0.0.0/8
87
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + metadata
88
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
89
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Check a list of URLs for liveness with bounded concurrency.
95
+ *
96
+ * @param {string[]} urls URLs to check (duplicates are de-duped).
97
+ * @param {object} [opts]
98
+ * @param {string[]} [opts.allowedHosts] Hosts allowed for external probes. A
99
+ * same-origin host (see opts.internalHost) is always allowed.
100
+ * @param {?string} [opts.internalHost] The site's own host; never needs listing.
101
+ * @param {number} [opts.maxConcurrency] In-flight cap (default 5).
102
+ * @param {number} [opts.timeoutMs] Per-request timeout (default 5000).
103
+ * @param {number} [opts.maxLinks] Hard ceiling on URLs checked (default 200).
104
+ * @param {Function} [opts.fetchImpl] Injected fetch (for tests); defaults to node-fetch.
105
+ * @returns {Promise<{checked: number, truncated: boolean, results: Array<{
106
+ * url: string, ok: boolean, status: ?number, skipped: boolean, reason: ?string}>}>}
107
+ */
108
+ export async function checkLinks(urls, opts = {}) {
109
+ const {
110
+ allowedHosts = [],
111
+ internalHost = null,
112
+ maxConcurrency = DEFAULT_CONCURRENCY,
113
+ timeoutMs = DEFAULT_TIMEOUT_MS,
114
+ maxLinks = DEFAULT_MAX_LINKS,
115
+ fetchImpl = nodeFetch,
116
+ } = opts;
117
+
118
+ const allow = new Set(allowedHosts.map((h) => String(h).toLowerCase()));
119
+ if (internalHost) allow.add(String(internalHost).toLowerCase());
120
+
121
+ const unique = [...new Set(urls)];
122
+ const truncated = unique.length > maxLinks;
123
+ const queue = unique.slice(0, maxLinks);
124
+
125
+ const results = [];
126
+ let cursor = 0;
127
+ /**
128
+ * Worker: pull URLs off the shared queue until drained.
129
+ * @returns {Promise<void>}
130
+ */
131
+ async function worker() {
132
+ while (cursor < queue.length) {
133
+ const url = queue[cursor++];
134
+ results.push(await checkOne(url, { allow, timeoutMs, fetchImpl }));
135
+ }
136
+ }
137
+ const workers = Array.from({ length: Math.max(1, Math.min(maxConcurrency, queue.length)) }, worker);
138
+ await Promise.all(workers);
139
+
140
+ return { checked: results.length, truncated, results };
141
+ }
142
+
143
+ /**
144
+ * Check a single URL, applying the SSRF guard and host allowlist before any
145
+ * network call. Safe failures (blocked/not-allowlisted) are returned as
146
+ * `skipped`, not errors.
147
+ * @param {string} url URL to check.
148
+ * @param {{allow: Set<string>, timeoutMs: number, fetchImpl: Function}} ctx Shared context.
149
+ * @returns {Promise<{url: string, ok: boolean, status: ?number, skipped: boolean, reason: ?string}>}
150
+ */
151
+ async function checkOne(url, { allow, timeoutMs, fetchImpl }) {
152
+ const guard = isSafeUrl(url);
153
+ if (!guard.safe) {
154
+ return { url, ok: false, status: null, skipped: true, reason: guard.reason };
155
+ }
156
+ if (!allow.has(guard.host.toLowerCase())) {
157
+ return { url, ok: false, status: null, skipped: true, reason: "host not in allowlist" };
158
+ }
159
+
160
+ // HEAD first; some servers reject HEAD (405/501) so retry once with GET.
161
+ const head = await timedFetch(url, "HEAD", { timeoutMs, fetchImpl });
162
+ if (head.status && head.status !== 405 && head.status !== 501) {
163
+ return finalize(url, head);
164
+ }
165
+ const get = await timedFetch(url, "GET", { timeoutMs, fetchImpl });
166
+ return finalize(url, get);
167
+ }
168
+
169
+ /**
170
+ * Build the public result record from a fetch outcome. Treats 2xx/3xx as ok.
171
+ * @param {string} url URL checked.
172
+ * @param {{status: ?number, error: ?string}} res Fetch outcome.
173
+ * @returns {{url: string, ok: boolean, status: ?number, skipped: boolean, reason: ?string}}
174
+ */
175
+ function finalize(url, res) {
176
+ if (res.error) return { url, ok: false, status: null, skipped: false, reason: res.error };
177
+ const ok = res.status >= 200 && res.status < 400;
178
+ return { url, ok, status: res.status, skipped: false, reason: ok ? null : `HTTP ${res.status}` };
179
+ }
180
+
181
+ /**
182
+ * Perform a single fetch with an abort-based timeout. Never throws — network and
183
+ * timeout failures are returned as `{ status: null, error }`.
184
+ * @param {string} url URL to fetch.
185
+ * @param {"HEAD"|"GET"} method HTTP method.
186
+ * @param {{timeoutMs: number, fetchImpl: Function}} ctx Timeout + fetch impl.
187
+ * @returns {Promise<{status: ?number, error: ?string}>}
188
+ */
189
+ async function timedFetch(url, method, { timeoutMs, fetchImpl }) {
190
+ const controller = new AbortController();
191
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
192
+ try {
193
+ const res = await fetchImpl(url, { method, redirect: "follow", signal: controller.signal });
194
+ return { status: res.status, error: null };
195
+ } catch (err) {
196
+ const reason = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : (err?.message || "request failed");
197
+ return { status: null, error: reason };
198
+ } finally {
199
+ clearTimeout(timer);
200
+ }
201
+ }
@@ -309,6 +309,23 @@ export async function callServerTool(site, toolName, args = {}) {
309
309
  }
310
310
  }
311
311
 
312
+ /**
313
+ * Extract the structured data a server tool returned, for callers (the audit
314
+ * tools) that need to inspect the payload rather than relay it. Prefers the MCP
315
+ * `structuredContent` field; otherwise parses the joined text content as JSON,
316
+ * falling back to the raw text when it isn't JSON.
317
+ * @param {object} result MCP tools/call result (as returned by callServerTool).
318
+ * @returns {*} Parsed structured data, raw text, or null when empty.
319
+ */
320
+ export function toolResultData(result) {
321
+ if (!result) return null;
322
+ if (result.structuredContent !== undefined) return result.structuredContent;
323
+ const text = extractTextContent(result);
324
+ if (!text) return null;
325
+ try { return JSON.parse(text); }
326
+ catch { return text; }
327
+ }
328
+
312
329
  /**
313
330
  * Pull the concatenated text from an MCP tool result's content array.
314
331
  * @param {object} result MCP tools/call result.