drupal-mcp-connector 2.26.0 → 3.0.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.
Files changed (37) hide show
  1. package/.agents/commands/drupal-audit-site-health.md +1 -1
  2. package/.agents/commands/drupal-report-accessibility-audit.md +4 -2
  3. package/.agents/commands/drupal-report-alias-coverage.md +4 -2
  4. package/.agents/commands/drupal-report-broken-embeds.md +4 -2
  5. package/.agents/commands/drupal-report-broken-links.md +4 -2
  6. package/.agents/commands/drupal-report-content-by-author.md +4 -2
  7. package/.agents/commands/drupal-report-duplicate-content.md +4 -2
  8. package/.agents/commands/drupal-report-missing-field.md +2 -2
  9. package/.agents/commands/drupal-report-orphan-pages.md +4 -2
  10. package/.agents/commands/drupal-report-orphaned-references.md +4 -2
  11. package/.agents/commands/drupal-report-pii-exposure.md +4 -2
  12. package/.agents/commands/drupal-report-readability.md +4 -2
  13. package/.agents/commands/drupal-report-recently-published.md +1 -1
  14. package/.agents/commands/drupal-report-revision-hotspots.md +4 -2
  15. package/.agents/commands/drupal-report-scheduled-content.md +4 -4
  16. package/.agents/commands/drupal-report-seo-audit.md +4 -2
  17. package/.agents/commands/drupal-report-seo-meta-coverage.md +4 -2
  18. package/.agents/commands/drupal-report-stale-content.md +4 -2
  19. package/.agents/commands/drupal-report-taxonomy-usage.md +2 -2
  20. package/.agents/commands/drupal-report-translation-coverage.md +4 -2
  21. package/.agents/commands/drupal-report-unpublished.md +1 -1
  22. package/.agents/commands/drupal-report-workflow-bottlenecks.md +4 -2
  23. package/.agents/commands/drupal-schedule-publish.md +4 -4
  24. package/.agents/commands/drupal-search-content.md +1 -1
  25. package/.agents/commands/drupal-search.md +1 -1
  26. package/CHANGELOG.md +46 -0
  27. package/package.json +1 -1
  28. package/src/lib/backends/jsonapi.js +19 -7
  29. package/src/lib/reports-support.js +68 -0
  30. package/src/tools/audit-composite.js +21 -11
  31. package/src/tools/nodes.js +12 -6
  32. package/src/tools/reports-content.js +106 -38
  33. package/src/tools/reports-extra.js +56 -32
  34. package/src/tools/reports-links.js +13 -13
  35. package/src/tools/reports.js +65 -42
  36. package/src/tools/scheduler.js +90 -62
  37. package/src/tools/search.js +16 -12
@@ -2,6 +2,8 @@
2
2
  * Shared helpers for the reporting tools — backend-neutral.
3
3
  */
4
4
 
5
+ import { assertReadAllowed } from "./security.js";
6
+
5
7
  /** Canonical base property names (read directly off the entity, not from `fields`). */
6
8
  const BASE_KEYS = new Set(["id", "title", "status", "langcode", "created", "changed", "url"]);
7
9
 
@@ -148,3 +150,69 @@ export function requestedFieldNames(fields) {
148
150
  const list = typeof fields === "string" ? [fields] : (Array.isArray(fields) ? fields : []);
149
151
  return [...new Set(list.filter((f) => typeof f === "string" && f.trim()).map((f) => f.trim()))];
150
152
  }
153
+
154
+ /**
155
+ * Return the content type a bundle-scoped tool reads, or fail clearly.
156
+ * Node tools used to default to `article`, which 404s on a site without that
157
+ * bundle (#403). There is no bundle every site has, so the caller names one.
158
+ * @param {?string} type Content type machine name from the caller.
159
+ * @param {string} tool Tool name, for the message.
160
+ * @param {string} [argName] Argument name, for the message.
161
+ * @returns {string} The content type.
162
+ * @throws {Error} When no type was given.
163
+ */
164
+ export function requireContentType(type, tool, argName = "type") {
165
+ if (typeof type === "string" && type.trim()) return type;
166
+ throw new Error(
167
+ `${tool} reads one content type: pass ${argName} (a node bundle machine name). ` +
168
+ "Call drupal_list_content_types to see the bundles on this site.",
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Node bundles a tool may read when the caller named none. Bundles that
174
+ * connector policy denies are left out; they are not the caller's to see.
175
+ * @param {{listContentTypes: Function}} backend Resolved backend.
176
+ * @param {object} sec Resolved security config.
177
+ * @returns {Promise<string[]>} Bundle machine names.
178
+ */
179
+ export async function readableNodeBundles(backend, sec) {
180
+ const types = await backend.listContentTypes();
181
+ return types.map((ct) => ct?.id).filter((id) => {
182
+ if (!id) return false;
183
+ try {
184
+ assertReadAllowed(sec, "node", id);
185
+ return true;
186
+ } catch {
187
+ return false;
188
+ }
189
+ });
190
+ }
191
+
192
+ /**
193
+ * List nodes from several bundles with one descriptor, merged newest first by
194
+ * `sortField`. A bundle that fails to load fails the whole call with the
195
+ * bundle named, so a partial list is never returned as complete (#403).
196
+ * @param {{listEntities: Function}} backend Resolved backend.
197
+ * @param {string[]} bundles Node bundles to read.
198
+ * @param {object} descriptor listEntities descriptor without `bundle`.
199
+ * @param {number} limit Max nodes returned.
200
+ * @param {string} [sortField] Canonical date property to merge by.
201
+ * @returns {Promise<object[]>} Canonical entities.
202
+ * @throws {Error} When any bundle fails to load.
203
+ */
204
+ export async function listNodesAcrossBundles(backend, bundles, descriptor, limit, sortField = "changed") {
205
+ const found = [];
206
+ for (const bundle of bundles) {
207
+ let res;
208
+ try {
209
+ res = await backend.listEntities({ ...descriptor, entityType: "node", bundle, page: { limit } });
210
+ } catch (err) {
211
+ throw new Error(`Could not read content type '${bundle}': ${err?.message || err}. Pass type to read one content type.`, { cause: err });
212
+ }
213
+ found.push(...(res.entities ?? []));
214
+ }
215
+ const key = (e) => String(new Map(Object.entries(e ?? {})).get(sortField) ?? "");
216
+ found.sort((x, y) => key(y).localeCompare(key(x)));
217
+ return found.slice(0, limit);
218
+ }
@@ -26,16 +26,16 @@ import * as reportsContent from "./reports-content.js";
26
26
  const SECTIONS = [
27
27
  // Content
28
28
  { key: "content_summary", group: "content", run: (o) => reports.handlers.drupal_report_content_summary({ site: o.site }), score: () => ({ issues: 0, high: 0 }) },
29
- { key: "stale_content", group: "content", run: (o) => reports.handlers.drupal_report_stale_content({ site: o.site, type: o.type, days: 180, limit: o.sampleSize }), score: (r) => ({ issues: (r.findings || r.nodes || []).length, high: 0 }) },
30
- { key: "seo_audit", group: "content", run: (o) => reports.handlers.drupal_report_seo_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
31
- { key: "accessibility", group: "content", run: (o) => reports.handlers.drupal_report_accessibility_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
32
- { key: "duplicate_content", group: "content", run: (o) => reportsContent.handlers.drupal_report_duplicate_content({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.duplicateGroups || 0, high: 0 }) },
33
- { key: "readability", group: "content", run: (o) => reportsContent.handlers.drupal_report_readability({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.hardToRead || []).length, high: 0 }) },
34
- { key: "pii_exposure", group: "content", run: (o) => reportsContent.handlers.drupal_report_pii_exposure({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.flaggedNodes || 0, high: r.flaggedNodes || 0 }) },
29
+ { key: "stale_content", needsType: true, group: "content", run: (o) => reports.handlers.drupal_report_stale_content({ site: o.site, type: o.type, days: 180, limit: o.sampleSize }), score: (r) => ({ issues: (r.findings || r.nodes || []).length, high: 0 }) },
30
+ { key: "seo_audit", needsType: true, group: "content", run: (o) => reports.handlers.drupal_report_seo_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
31
+ { key: "accessibility", needsType: true, group: "content", run: (o) => reports.handlers.drupal_report_accessibility_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
32
+ { key: "duplicate_content", needsType: true, group: "content", run: (o) => reportsContent.handlers.drupal_report_duplicate_content({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.duplicateGroups || 0, high: 0 }) },
33
+ { key: "readability", needsType: true, group: "content", run: (o) => reportsContent.handlers.drupal_report_readability({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.hardToRead || []).length, high: 0 }) },
34
+ { key: "pii_exposure", needsType: true, group: "content", run: (o) => reportsContent.handlers.drupal_report_pii_exposure({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.flaggedNodes || 0, high: r.flaggedNodes || 0 }) },
35
35
  // Links
36
- { key: "broken_links", group: "links", run: (o) => reportsLinks.handlers.drupal_report_broken_links({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.summary?.malformed || 0, high: 0 }) },
36
+ { key: "broken_links", needsType: true, group: "links", run: (o) => reportsLinks.handlers.drupal_report_broken_links({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.summary?.malformed || 0, high: 0 }) },
37
37
  { key: "redirect_health", group: "links", run: (o) => reportsLinks.handlers.drupal_report_redirect_health({ site: o.site }), score: (r) => ({ issues: (r.summary?.duplicateSources || 0) + (r.summary?.chains || 0) + (r.summary?.loops || 0), high: r.summary?.loops || 0 }) },
38
- { key: "alias_coverage", group: "links", run: (o) => reportsLinks.handlers.drupal_report_alias_coverage({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.totalMissingAlias || 0) + (r.aliasConflicts?.conflicting?.length || 0), high: 0 }) },
38
+ { key: "alias_coverage", needsType: true, group: "links", run: (o) => reportsLinks.handlers.drupal_report_alias_coverage({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.totalMissingAlias || 0) + (r.aliasConflicts?.conflicting?.length || 0), high: 0 }) },
39
39
  { key: "log_404", group: "links", run: (o) => reportsLinks.handlers.drupal_report_404_log({ site: o.site }), score: (r) => ({ issues: (r.findings || []).length, high: 0 }) },
40
40
  // Config
41
41
  { key: "config_best_practices", group: "config", run: (o) => reportsConfig.handlers.drupal_audit_config_best_practices({ site: o.site }), score: (r) => ({ issues: (r.counts?.high || 0) + (r.counts?.medium || 0) + (r.counts?.low || 0), high: r.counts?.high || 0 }) },
@@ -81,10 +81,13 @@ function grade(high, issues) {
81
81
  /**
82
82
  * Run a battery of audits and roll the results into a scored dashboard.
83
83
  *
84
+ * Sections that audit one content type (`needsType`) are reported unavailable
85
+ * when `type` is omitted; there is no default bundle (#403).
86
+ *
84
87
  * @param {object} args - { site?, type?, sampleSize?, sections? }.
85
88
  * @returns {Promise<object>} The dashboard: per-section status + a roll-up grade.
86
89
  */
87
- async function siteHealth({ site: siteName, type = "article", sampleSize = 50, sections }) {
90
+ async function siteHealth({ site: siteName, type, sampleSize = 50, sections }) {
88
91
  const site = getSiteConfig(siteName);
89
92
  const selected = sections && sections.length
90
93
  ? SECTIONS.filter((s) => sections.includes(s.key))
@@ -95,6 +98,13 @@ async function siteHealth({ site: siteName, type = "article", sampleSize = 50, s
95
98
  let totalHigh = 0;
96
99
  let totalIssues = 0;
97
100
  for (const section of selected) {
101
+ if (section.needsType && !type) {
102
+ results.push({
103
+ key: section.key, group: section.group, status: "unavailable",
104
+ reason: "This section audits one content type: pass type. Call drupal_list_content_types to see the bundles on this site.",
105
+ });
106
+ continue;
107
+ }
98
108
  try {
99
109
  const result = await section.run(opts);
100
110
  if (isGated(result)) {
@@ -113,7 +123,7 @@ async function siteHealth({ site: siteName, type = "article", sampleSize = 50, s
113
123
  const byStatus = (s) => results.filter((r) => r.status === s).length;
114
124
  return {
115
125
  site: site._name,
116
- type,
126
+ type: type || null,
117
127
  grade: grade(totalHigh, totalIssues),
118
128
  summary: {
119
129
  sectionsRun: results.length,
@@ -139,7 +149,7 @@ export const definitions = [
139
149
  type: "object",
140
150
  properties: {
141
151
  site: { type: "string" },
142
- type: { type: "string", description: "Primary content type for content/link sections (default: article)" },
152
+ type: { type: "string", description: "Content type for the content/link sections. Omit it and those sections are reported unavailable; site-wide sections still run." },
143
153
  sampleSize: { type: "number", default: 50, description: "Per-section scan cap (kept small for a fast roll-up)" },
144
154
  sections: { type: "array", items: { type: "string", enum: SECTION_KEYS }, description: `Subset of sections to run (default: all). Available: ${SECTION_KEYS.join(", ")}` },
145
155
  },
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { getSiteConfig } from "../lib/config.js";
11
11
  import { resolveBackend } from "../lib/backends/index.js";
12
+ import { readableNodeBundles, listNodesAcrossBundles } from "../lib/reports-support.js";
12
13
  import {
13
14
  resolveSecurityConfig, redactCanonicalEntity,
14
15
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
@@ -357,7 +358,10 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
357
358
  }
358
359
 
359
360
  /**
360
- * Search nodes by title substring (defaults to the article bundle).
361
+ * Search nodes by title substring. With `type`, searches that bundle. Without
362
+ * it, searches every node bundle the connector may read and merges by
363
+ * `changed` (newest first). A bundle that fails to load fails the search with
364
+ * the bundle named, so a partial result is never returned as complete (#403).
361
365
  *
362
366
  * @param {object} args - { site?, query, type?, status?, limit? }.
363
367
  * @returns {Promise<object[]>} Redacted matching nodes.
@@ -365,13 +369,15 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
365
369
  async function searchContent({ site: siteName, query, type, status, limit = 10 }) {
366
370
  const site = getSiteConfig(siteName);
367
371
  const sec = resolveSecurityConfig(site);
368
- const bundle = type || "article";
369
- assertReadAllowed(sec, "node", bundle);
372
+ assertReadAllowed(sec, "node", type);
370
373
  const backend = await resolveBackend(site);
371
374
  const filters = [{ field: "title", op: "contains", value: query }];
372
375
  if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
373
- const res = await backend.listEntities({ entityType: "node", bundle, filters, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
374
- return res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
376
+ const descriptor = { filters, sort: [{ field: "changed", dir: "desc" }] };
377
+ const found = type
378
+ ? (await backend.listEntities({ ...descriptor, entityType: "node", bundle: type, page: { limit } })).entities
379
+ : await listNodesAcrossBundles(backend, await readableNodeBundles(backend, sec), descriptor, limit);
380
+ return found.map((e) => redactCanonicalEntity(e, sec, "node"));
375
381
  }
376
382
 
377
383
  /**
@@ -621,7 +627,7 @@ export const definitions = [
621
627
  properties: {
622
628
  site: { type: "string" },
623
629
  query: { type: "string", description: "Search term to match against node titles" },
624
- type: { type: "string", description: "Limit to this content type (default: article)" },
630
+ type: { type: "string", description: "Content type machine name. Omit to search every node bundle (merged, newest first); if any bundle cannot be read the search fails and names it." },
625
631
  status: { type: "boolean", description: "Filter by publish status" },
626
632
  limit: { type: "number", default: 10 },
627
633
  },
@@ -6,6 +6,10 @@
6
6
  * scheduled-content state, body readability, orphan pages (no inbound internal
7
7
  * links), accidental PII exposure, and structured-meta (SEO) coverage.
8
8
  *
9
+ * Each report except scheduled content scans one node bundle named by `type`
10
+ * (there is no default bundle; #403). Scheduled content scans every bundle when
11
+ * `type` is omitted.
12
+ *
9
13
  * Each handler asserts read access in-handler, samples via collectEntities, and
10
14
  * flags `approximate: true` when the scan is sampling-bounded. Audits that need a
11
15
  * field the site doesn't expose (moderation state, scheduler dates, metatag
@@ -17,6 +21,7 @@ import { resolveBackend } from "../lib/backends/index.js";
17
21
  import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
18
22
  import {
19
23
  collectEntities, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince,
24
+ requireContentType,
20
25
  } from "../lib/reports-support.js";
21
26
  import { bodyHtml, extractAnchors, classifyLink, normalizePath } from "../lib/audit-support.js";
22
27
  import {
@@ -61,11 +66,11 @@ function scalar(entity, candidates) {
61
66
  * @returns {Promise<object>} Duplicate-title groups.
62
67
  */
63
68
  async function duplicateContent({ site: siteName, type, sampleSize = 200 }) {
69
+ const contentType = requireContentType(type, "drupal_report_duplicate_content");
64
70
  const site = getSiteConfig(siteName);
65
71
  const sec = resolveSecurityConfig(site);
66
- assertReadAllowed(sec, "node", type);
72
+ assertReadAllowed(sec, "node", contentType);
67
73
  const backend = await resolveBackend(site);
68
- const contentType = type || "article";
69
74
  const nodes = await collectEntities(
70
75
  backend,
71
76
  { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
@@ -106,11 +111,11 @@ async function duplicateContent({ site: siteName, type, sampleSize = 200 }) {
106
111
  * @returns {Promise<object>} Stuck-content findings.
107
112
  */
108
113
  async function workflowBottlenecks({ site: siteName, type, days = 30, states, sampleSize = 200, langcode }) {
114
+ const contentType = requireContentType(type, "drupal_report_workflow_bottlenecks");
109
115
  const site = getSiteConfig(siteName);
110
116
  const sec = resolveSecurityConfig(site);
111
- assertReadAllowed(sec, "node", type);
117
+ assertReadAllowed(sec, "node", contentType);
112
118
  const backend = await resolveBackend(site);
113
- const contentType = type || "article";
114
119
  const targetStates = (states && states.length ? states : ["draft", "needs_review", "review"]).map((s) => s.toLowerCase());
115
120
  const targetLang = langcode ? assertDraftLangcode(langcode) : null;
116
121
 
@@ -200,11 +205,11 @@ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sa
200
205
  * @returns {Promise<object>} Coverage, or unavailable.
201
206
  */
202
207
  async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
208
+ const contentType = requireContentType(type, "drupal_report_translation_coverage");
203
209
  const site = getSiteConfig(siteName);
204
210
  const sec = resolveSecurityConfig(site);
205
- assertReadAllowed(sec, "node", type);
211
+ assertReadAllowed(sec, "node", contentType);
206
212
  const backend = await resolveBackend(site);
207
- const contentType = type || "article";
208
213
  const nodes = await collectEntities(
209
214
  backend,
210
215
  { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
@@ -283,19 +288,18 @@ async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
283
288
  // ---------------------------------------------------------------------------
284
289
 
285
290
  /**
286
- * Report content with Scheduler publish/unpublish dates set, split into pending
287
- * (date in the future) and overdue (date in the past but the action hasn't run).
288
- * Gated when the scheduler fields aren't exposed.
291
+ * Scan one bundle for Scheduler dates.
289
292
  *
290
- * @param {object} args - { site?, type?, sampleSize? }.
291
- * @returns {Promise<object>} Pending and overdue scheduled items.
293
+ * Whether Scheduler is exposed is decided from the attribute key, not its
294
+ * value: a node with `publish_on: null` has the field and nothing scheduled
295
+ * (#403). A bundle with no nodes cannot show either way, so it reports zero
296
+ * pending and overdue with `schedulerFields: "unknown"`.
297
+ * @param {object} backend Resolved backend.
298
+ * @param {string} contentType Node bundle.
299
+ * @param {number} sampleSize Max nodes to scan.
300
+ * @returns {Promise<object>} Per-bundle result.
292
301
  */
293
- async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
294
- const site = getSiteConfig(siteName);
295
- const sec = resolveSecurityConfig(site);
296
- assertReadAllowed(sec, "node", type);
297
- const backend = await resolveBackend(site);
298
- const contentType = type || "article";
302
+ async function scanScheduledBundle(backend, contentType, sampleSize) {
299
303
  const nodes = await collectEntities(
300
304
  backend,
301
305
  { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
@@ -308,7 +312,6 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
308
312
  const overdue = [];
309
313
  for (const n of nodes) {
310
314
  for (const [field, action] of [["publish_on", "publish"], ["unpublish_on", "unpublish"]]) {
311
- // Presence of the key (even null) means Scheduler is exposed (#403).
312
315
  const { present, value } = fieldPresence(n, field);
313
316
  if (present) sawField = true;
314
317
  const raw = value && typeof value === "object" && "value" in value ? value.value : value;
@@ -321,11 +324,19 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
321
324
  }
322
325
  }
323
326
 
324
- if (!sawField) {
325
- return { contentType, gated: true, reason: "No Scheduler publish_on/unpublish_on fields exposed.", scanned: nodes.length };
327
+ if (nodes.length && !sawField) {
328
+ return {
329
+ contentType,
330
+ gated: true,
331
+ schedulerFields: "absent",
332
+ reason: "No sampled node carries a publish_on or unpublish_on key: Scheduler is not enabled for this content type, or this account cannot view those fields.",
333
+ scanned: nodes.length,
334
+ approximate: nodes.length >= sampleSize,
335
+ };
326
336
  }
327
337
  return {
328
338
  contentType,
339
+ schedulerFields: sawField ? "present" : "unknown",
329
340
  scanned: nodes.length,
330
341
  approximate: nodes.length >= sampleSize,
331
342
  summary: { pending: pending.length, overdue: overdue.length },
@@ -333,6 +344,56 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
333
344
  };
334
345
  }
335
346
 
347
+ /**
348
+ * Report content with Scheduler publish/unpublish dates set, split into pending
349
+ * (date in the future) and overdue (date in the past but the action hasn't run).
350
+ *
351
+ * With `type`, scans that bundle. Without it, scans every node bundle and
352
+ * returns a combined summary plus one row per bundle; a bundle that cannot be
353
+ * read is listed with its error rather than failing the whole report.
354
+ *
355
+ * @param {object} args - { site?, type?, sampleSize? }.
356
+ * @returns {Promise<object>} Pending and overdue scheduled items.
357
+ */
358
+ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
359
+ const site = getSiteConfig(siteName);
360
+ const sec = resolveSecurityConfig(site);
361
+ assertReadAllowed(sec, "node", type);
362
+ const backend = await resolveBackend(site);
363
+ if (type) return scanScheduledBundle(backend, type, sampleSize);
364
+
365
+ const bundles = (await backend.listContentTypes()).map((ct) => ct.id).filter(Boolean);
366
+ const byContentType = [];
367
+ const pending = [];
368
+ const overdue = [];
369
+ let approximate = false;
370
+ for (const bundle of bundles) {
371
+ let row;
372
+ try {
373
+ assertReadAllowed(sec, "node", bundle);
374
+ row = await scanScheduledBundle(backend, bundle, sampleSize);
375
+ } catch (err) {
376
+ byContentType.push({ contentType: bundle, error: err?.message || String(err) });
377
+ continue;
378
+ }
379
+ approximate = approximate || Boolean(row.approximate);
380
+ for (const rec of row.findings?.pending ?? []) pending.push({ ...rec, contentType: bundle });
381
+ for (const rec of row.findings?.overdue ?? []) overdue.push({ ...rec, contentType: bundle });
382
+ const summaryRow = { ...row };
383
+ delete summaryRow.findings;
384
+ byContentType.push(summaryRow);
385
+ }
386
+ return {
387
+ contentType: null,
388
+ scannedBundles: bundles.length,
389
+ scanned: byContentType.reduce((n, r) => n + (r.scanned || 0), 0),
390
+ approximate,
391
+ summary: { pending: pending.length, overdue: overdue.length },
392
+ byContentType,
393
+ findings: { pending, overdue },
394
+ };
395
+ }
396
+
336
397
  /**
337
398
  * Coerce a scheduler date value (unix seconds, ms, or ISO string) to epoch ms.
338
399
  * @param {*} raw Field value.
@@ -358,11 +419,11 @@ function toMillis(raw) {
358
419
  * @returns {Promise<object>} Per-node scores and aggregate.
359
420
  */
360
421
  async function readability({ site: siteName, type, sampleSize = 100, hardThreshold = 30 }) {
422
+ const contentType = requireContentType(type, "drupal_report_readability");
361
423
  const site = getSiteConfig(siteName);
362
424
  const sec = resolveSecurityConfig(site);
363
- assertReadAllowed(sec, "node", type);
425
+ assertReadAllowed(sec, "node", contentType);
364
426
  const backend = await resolveBackend(site);
365
- const contentType = type || "article";
366
427
  const nodes = await collectEntities(
367
428
  backend,
368
429
  { entityType: "node", bundle: contentType, filters: [{ field: "status", op: "eq", value: true }] },
@@ -440,11 +501,11 @@ function countSyllables(word) {
440
501
  * @returns {Promise<object>} Orphan pages.
441
502
  */
442
503
  async function orphanPages({ site: siteName, type, sampleSize = 200 }) {
504
+ const contentType = requireContentType(type, "drupal_report_orphan_pages");
443
505
  const site = getSiteConfig(siteName);
444
506
  const sec = resolveSecurityConfig(site);
445
- assertReadAllowed(sec, "node", type);
507
+ assertReadAllowed(sec, "node", contentType);
446
508
  const backend = await resolveBackend(site);
447
- const contentType = type || "article";
448
509
  const baseUrl = site.baseUrl;
449
510
  const nodes = await collectEntities(
450
511
  backend,
@@ -493,11 +554,11 @@ const PII_PATTERNS = new Map([
493
554
  * @returns {Promise<object>} PII findings, masked.
494
555
  */
495
556
  async function piiExposure({ site: siteName, type, sampleSize = 100, kinds }) {
557
+ const contentType = requireContentType(type, "drupal_report_pii_exposure");
496
558
  const site = getSiteConfig(siteName);
497
559
  const sec = resolveSecurityConfig(site);
498
- assertReadAllowed(sec, "node", type);
560
+ assertReadAllowed(sec, "node", contentType);
499
561
  const backend = await resolveBackend(site);
500
- const contentType = type || "article";
501
562
  const active = (kinds && kinds.length ? kinds : [...PII_PATTERNS.keys()]).filter((k) => PII_PATTERNS.has(k));
502
563
  const nodes = await collectEntities(
503
564
  backend,
@@ -570,11 +631,11 @@ const DEFAULT_META_FIELDS = ["field_meta_tags", "field_metatag", "metatag", "fie
570
631
  * @returns {Promise<object>} Per-field coverage and nodes missing all meta.
571
632
  */
572
633
  async function seoMetaCoverage({ site: siteName, type, fields, sampleSize = 100 }) {
634
+ const contentType = requireContentType(type, "drupal_report_seo_meta_coverage");
573
635
  const site = getSiteConfig(siteName);
574
636
  const sec = resolveSecurityConfig(site);
575
- assertReadAllowed(sec, "node", type);
637
+ assertReadAllowed(sec, "node", contentType);
576
638
  const backend = await resolveBackend(site);
577
- const contentType = type || "article";
578
639
  const named = requestedFieldNames(fields);
579
640
  const requested = named.length > 0;
580
641
  const metaFields = requested ? named : DEFAULT_META_FIELDS;
@@ -638,9 +699,10 @@ export const definitions = [
638
699
  description: "Find duplicate / near-duplicate titles within a content type (normalized title grouping). Surfaces accidental re-publishing and content cannibalization.",
639
700
  inputSchema: {
640
701
  type: "object",
702
+ required: ["type"],
641
703
  properties: {
642
704
  site: { type: "string" },
643
- type: { type: "string", description: "Content type (default: article)" },
705
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
644
706
  sampleSize: { type: "number", default: 200, description: "Max nodes to scan" },
645
707
  },
646
708
  },
@@ -650,9 +712,10 @@ export const definitions = [
650
712
  description: "Find content stuck in a non-published moderation state (draft/needs_review) beyond N days — editorial bottlenecks. Reads moderation_state; gated when content_moderation isn't exposed.",
651
713
  inputSchema: {
652
714
  type: "object",
715
+ required: ["type"],
653
716
  properties: {
654
717
  site: { type: "string" },
655
- type: { type: "string", description: "Content type (default: article)" },
718
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
656
719
  days: { type: "number", default: 30, description: "Days-in-state threshold" },
657
720
  states: { type: "array", items: { type: "string" }, description: "Moderation states to treat as bottlenecks" },
658
721
  langcode: { type: "string", description: "Limit to this translation (Sentinel inventory). Omit for the default-language field on each node." },
@@ -665,22 +728,23 @@ export const definitions = [
665
728
  description: "Per-node translation coverage from Sentinel's inventory (missing non-default language, outdated core flag, language counts). Without Sentinel the report is unavailable — JSON:API only shows the default language, so a histogram would be misleading.",
666
729
  inputSchema: {
667
730
  type: "object",
731
+ required: ["type"],
668
732
  properties: {
669
733
  site: { type: "string" },
670
- type: { type: "string", description: "Content type (default: article)" },
734
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
671
735
  sampleSize: { type: "number", default: 100, description: "Max nodes to inventory" },
672
736
  },
673
737
  },
674
738
  },
675
739
  {
676
740
  name: "drupal_report_scheduled_content",
677
- description: "Report content with Scheduler publish/unpublish dates set, split into pending (future) and overdue (past, action not run). Gated when scheduler fields aren't exposed.",
741
+ description: "Report content with Scheduler publish/unpublish dates set, split into pending (future) and overdue (past, action not run). Omit type to scan every node bundle in one call (one row per bundle in byContentType). A bundle is gated only when sampled nodes carry no publish_on/unpublish_on key at all; fields that exist but are empty report pending 0 / overdue 0. A bundle with no nodes reports zero counts with schedulerFields 'unknown'.",
678
742
  inputSchema: {
679
743
  type: "object",
680
744
  properties: {
681
745
  site: { type: "string" },
682
- type: { type: "string", description: "Content type (default: article)" },
683
- sampleSize: { type: "number", default: 200 },
746
+ type: { type: "string", description: "Content type machine name. Omit to scan every node bundle." },
747
+ sampleSize: { type: "number", default: 200, description: "Max nodes to scan per content type" },
684
748
  },
685
749
  },
686
750
  },
@@ -689,9 +753,10 @@ export const definitions = [
689
753
  description: "Score body readability (Flesch Reading Ease) for a content type and flag hard-to-read content and structural issues (no H2 subheadings, multiple H1s).",
690
754
  inputSchema: {
691
755
  type: "object",
756
+ required: ["type"],
692
757
  properties: {
693
758
  site: { type: "string" },
694
- type: { type: "string", description: "Content type (default: article)" },
759
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
695
760
  sampleSize: { type: "number", default: 100 },
696
761
  hardThreshold: { type: "number", default: 30, description: "Flag content scoring below this" },
697
762
  },
@@ -702,9 +767,10 @@ export const definitions = [
702
767
  description: "Find published pages with no inbound internal links from other sampled pages — content islands. Best-effort over the sampled set.",
703
768
  inputSchema: {
704
769
  type: "object",
770
+ required: ["type"],
705
771
  properties: {
706
772
  site: { type: "string" },
707
- type: { type: "string", description: "Content type (default: article)" },
773
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
708
774
  sampleSize: { type: "number", default: 200 },
709
775
  },
710
776
  },
@@ -714,9 +780,10 @@ export const definitions = [
714
780
  description: "Scan published body content for accidentally exposed PII (emails, US SSNs, phone numbers). Matched values are masked in the output so the report itself doesn't leak data.",
715
781
  inputSchema: {
716
782
  type: "object",
783
+ required: ["type"],
717
784
  properties: {
718
785
  site: { type: "string" },
719
- type: { type: "string", description: "Content type (default: article)" },
786
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
720
787
  sampleSize: { type: "number", default: 100 },
721
788
  kinds: { type: "array", items: { type: "string", enum: ["email", "ssn", "phone"] }, description: "PII kinds to scan (default: all)" },
722
789
  },
@@ -727,9 +794,10 @@ export const definitions = [
727
794
  description: "Report structured-meta (SEO) coverage for a content type: how many sampled nodes populate each meta field (metatag, meta description). Complements drupal_report_seo_audit with explicit per-field coverage. Reads scalar and entity-reference fields. A field absent from every sampled node has `present: false` and `coverage: null` (unknown, not 0), and is listed in `notVisible` when you named it. When none of the checked fields is visible, no node is flagged and `nodesMissingAllMeta` is null: JSON:API leaves out a field this account may not view.",
728
795
  inputSchema: {
729
796
  type: "object",
797
+ required: ["type"],
730
798
  properties: {
731
799
  site: { type: "string" },
732
- type: { type: "string", description: "Content type (default: article)" },
800
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
733
801
  fields: { type: "array", items: { type: "string" }, description: "Meta field machine names to check" },
734
802
  sampleSize: { type: "number", default: 100 },
735
803
  },