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 +52 -0
- package/README.md +7 -2
- package/config/config.example.json +12 -1
- package/package.json +1 -1
- package/src/index.js +27 -1
- package/src/lib/audit-sources.js +66 -0
- package/src/lib/audit-support.js +170 -0
- package/src/lib/link-checker.js +201 -0
- package/src/lib/server-tools.js +17 -0
- package/src/tools/audit-composite.js +152 -0
- package/src/tools/drush.js +25 -3
- package/src/tools/reports-config.js +634 -0
- package/src/tools/reports-content.js +649 -0
- package/src/tools/reports-links.js +595 -0
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
|
-
###
|
|
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
|
|
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
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,
|
|
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
|
+
}
|
package/src/lib/server-tools.js
CHANGED
|
@@ -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.
|