drupal-mcp-connector 2.25.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 (64) hide show
  1. package/.agents/commands/drupal-audit-site-health.md +1 -1
  2. package/.agents/commands/drupal-entity-update.md +1 -1
  3. package/.agents/commands/drupal-report-accessibility-audit.md +4 -2
  4. package/.agents/commands/drupal-report-alias-coverage.md +4 -2
  5. package/.agents/commands/drupal-report-broken-embeds.md +4 -2
  6. package/.agents/commands/drupal-report-broken-links.md +4 -2
  7. package/.agents/commands/drupal-report-content-by-author.md +4 -2
  8. package/.agents/commands/drupal-report-duplicate-content.md +4 -2
  9. package/.agents/commands/drupal-report-missing-field.md +2 -2
  10. package/.agents/commands/drupal-report-orphan-pages.md +4 -2
  11. package/.agents/commands/drupal-report-orphaned-references.md +4 -2
  12. package/.agents/commands/drupal-report-pii-exposure.md +4 -2
  13. package/.agents/commands/drupal-report-readability.md +4 -2
  14. package/.agents/commands/drupal-report-recently-published.md +1 -1
  15. package/.agents/commands/drupal-report-revision-hotspots.md +4 -2
  16. package/.agents/commands/drupal-report-scheduled-content.md +4 -4
  17. package/.agents/commands/drupal-report-seo-audit.md +4 -2
  18. package/.agents/commands/drupal-report-seo-meta-coverage.md +4 -2
  19. package/.agents/commands/drupal-report-stale-content.md +4 -2
  20. package/.agents/commands/drupal-report-taxonomy-usage.md +2 -2
  21. package/.agents/commands/drupal-report-translation-coverage.md +4 -2
  22. package/.agents/commands/drupal-report-unpublished.md +1 -1
  23. package/.agents/commands/drupal-report-workflow-bottlenecks.md +4 -2
  24. package/.agents/commands/drupal-schedule-publish.md +5 -3
  25. package/.agents/commands/drupal-search-content.md +1 -1
  26. package/.agents/commands/drupal-search.md +1 -1
  27. package/.agents/commands/drupal-update-node.md +1 -1
  28. package/CHANGELOG.md +114 -0
  29. package/README.md +57 -1
  30. package/bin/drupal-mcp-doctor.js +16 -0
  31. package/bin/drupal-mcp-wizard.js +17 -0
  32. package/package.json +10 -5
  33. package/src/index.js +19 -19
  34. package/src/lib/backends/jsonapi.js +19 -7
  35. package/src/lib/dispatch.js +22 -0
  36. package/src/lib/doctor.js +645 -0
  37. package/src/lib/module-tools.js +33 -2
  38. package/src/lib/operations.js +1 -1
  39. package/src/lib/patch-preflight.js +32 -32
  40. package/src/lib/principal.js +13 -5
  41. package/src/lib/reports-support.js +68 -0
  42. package/src/lib/security.js +42 -44
  43. package/src/lib/sentinel-draft.js +59 -3
  44. package/src/lib/server-tools.js +3 -3
  45. package/src/lib/transports.js +153 -0
  46. package/src/lib/version-skew.js +193 -0
  47. package/src/lib/wizard.js +902 -0
  48. package/src/lib/workflow-prompts.js +5 -5
  49. package/src/tools/audit-composite.js +21 -11
  50. package/src/tools/codegen.js +2 -2
  51. package/src/tools/config.js +11 -6
  52. package/src/tools/entities.js +1 -1
  53. package/src/tools/index.js +1 -1
  54. package/src/tools/media.js +2 -2
  55. package/src/tools/nodes.js +17 -9
  56. package/src/tools/reports-config.js +3 -3
  57. package/src/tools/reports-content.js +109 -39
  58. package/src/tools/reports-extra.js +56 -32
  59. package/src/tools/reports-links.js +13 -13
  60. package/src/tools/reports.js +65 -42
  61. package/src/tools/revisions.js +2 -1
  62. package/src/tools/scheduler.js +142 -27
  63. package/src/tools/search.js +16 -12
  64. package/src/tools/translations.js +7 -1
@@ -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,9 +312,10 @@ 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
- const raw = scalar(n, [field]);
315
+ const { present, value } = fieldPresence(n, field);
316
+ if (present) sawField = true;
317
+ const raw = value && typeof value === "object" && "value" in value ? value.value : value;
312
318
  if (raw === undefined || raw === null || raw === "" || raw === 0) continue;
313
- sawField = true;
314
319
  const ms = toMillis(raw);
315
320
  if (ms === null) continue;
316
321
  const rec = { id: n.id, title: n.title, action, when: new Date(ms).toISOString(), path: n.url };
@@ -319,11 +324,19 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
319
324
  }
320
325
  }
321
326
 
322
- if (!sawField) {
323
- 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
+ };
324
336
  }
325
337
  return {
326
338
  contentType,
339
+ schedulerFields: sawField ? "present" : "unknown",
327
340
  scanned: nodes.length,
328
341
  approximate: nodes.length >= sampleSize,
329
342
  summary: { pending: pending.length, overdue: overdue.length },
@@ -331,6 +344,56 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
331
344
  };
332
345
  }
333
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
+
334
397
  /**
335
398
  * Coerce a scheduler date value (unix seconds, ms, or ISO string) to epoch ms.
336
399
  * @param {*} raw Field value.
@@ -356,11 +419,11 @@ function toMillis(raw) {
356
419
  * @returns {Promise<object>} Per-node scores and aggregate.
357
420
  */
358
421
  async function readability({ site: siteName, type, sampleSize = 100, hardThreshold = 30 }) {
422
+ const contentType = requireContentType(type, "drupal_report_readability");
359
423
  const site = getSiteConfig(siteName);
360
424
  const sec = resolveSecurityConfig(site);
361
- assertReadAllowed(sec, "node", type);
425
+ assertReadAllowed(sec, "node", contentType);
362
426
  const backend = await resolveBackend(site);
363
- const contentType = type || "article";
364
427
  const nodes = await collectEntities(
365
428
  backend,
366
429
  { entityType: "node", bundle: contentType, filters: [{ field: "status", op: "eq", value: true }] },
@@ -438,11 +501,11 @@ function countSyllables(word) {
438
501
  * @returns {Promise<object>} Orphan pages.
439
502
  */
440
503
  async function orphanPages({ site: siteName, type, sampleSize = 200 }) {
504
+ const contentType = requireContentType(type, "drupal_report_orphan_pages");
441
505
  const site = getSiteConfig(siteName);
442
506
  const sec = resolveSecurityConfig(site);
443
- assertReadAllowed(sec, "node", type);
507
+ assertReadAllowed(sec, "node", contentType);
444
508
  const backend = await resolveBackend(site);
445
- const contentType = type || "article";
446
509
  const baseUrl = site.baseUrl;
447
510
  const nodes = await collectEntities(
448
511
  backend,
@@ -491,11 +554,11 @@ const PII_PATTERNS = new Map([
491
554
  * @returns {Promise<object>} PII findings, masked.
492
555
  */
493
556
  async function piiExposure({ site: siteName, type, sampleSize = 100, kinds }) {
557
+ const contentType = requireContentType(type, "drupal_report_pii_exposure");
494
558
  const site = getSiteConfig(siteName);
495
559
  const sec = resolveSecurityConfig(site);
496
- assertReadAllowed(sec, "node", type);
560
+ assertReadAllowed(sec, "node", contentType);
497
561
  const backend = await resolveBackend(site);
498
- const contentType = type || "article";
499
562
  const active = (kinds && kinds.length ? kinds : [...PII_PATTERNS.keys()]).filter((k) => PII_PATTERNS.has(k));
500
563
  const nodes = await collectEntities(
501
564
  backend,
@@ -568,11 +631,11 @@ const DEFAULT_META_FIELDS = ["field_meta_tags", "field_metatag", "metatag", "fie
568
631
  * @returns {Promise<object>} Per-field coverage and nodes missing all meta.
569
632
  */
570
633
  async function seoMetaCoverage({ site: siteName, type, fields, sampleSize = 100 }) {
634
+ const contentType = requireContentType(type, "drupal_report_seo_meta_coverage");
571
635
  const site = getSiteConfig(siteName);
572
636
  const sec = resolveSecurityConfig(site);
573
- assertReadAllowed(sec, "node", type);
637
+ assertReadAllowed(sec, "node", contentType);
574
638
  const backend = await resolveBackend(site);
575
- const contentType = type || "article";
576
639
  const named = requestedFieldNames(fields);
577
640
  const requested = named.length > 0;
578
641
  const metaFields = requested ? named : DEFAULT_META_FIELDS;
@@ -636,9 +699,10 @@ export const definitions = [
636
699
  description: "Find duplicate / near-duplicate titles within a content type (normalized title grouping). Surfaces accidental re-publishing and content cannibalization.",
637
700
  inputSchema: {
638
701
  type: "object",
702
+ required: ["type"],
639
703
  properties: {
640
704
  site: { type: "string" },
641
- type: { type: "string", description: "Content type (default: article)" },
705
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
642
706
  sampleSize: { type: "number", default: 200, description: "Max nodes to scan" },
643
707
  },
644
708
  },
@@ -648,9 +712,10 @@ export const definitions = [
648
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.",
649
713
  inputSchema: {
650
714
  type: "object",
715
+ required: ["type"],
651
716
  properties: {
652
717
  site: { type: "string" },
653
- type: { type: "string", description: "Content type (default: article)" },
718
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
654
719
  days: { type: "number", default: 30, description: "Days-in-state threshold" },
655
720
  states: { type: "array", items: { type: "string" }, description: "Moderation states to treat as bottlenecks" },
656
721
  langcode: { type: "string", description: "Limit to this translation (Sentinel inventory). Omit for the default-language field on each node." },
@@ -663,22 +728,23 @@ export const definitions = [
663
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.",
664
729
  inputSchema: {
665
730
  type: "object",
731
+ required: ["type"],
666
732
  properties: {
667
733
  site: { type: "string" },
668
- type: { type: "string", description: "Content type (default: article)" },
734
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
669
735
  sampleSize: { type: "number", default: 100, description: "Max nodes to inventory" },
670
736
  },
671
737
  },
672
738
  },
673
739
  {
674
740
  name: "drupal_report_scheduled_content",
675
- 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'.",
676
742
  inputSchema: {
677
743
  type: "object",
678
744
  properties: {
679
745
  site: { type: "string" },
680
- type: { type: "string", description: "Content type (default: article)" },
681
- 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" },
682
748
  },
683
749
  },
684
750
  },
@@ -687,9 +753,10 @@ export const definitions = [
687
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).",
688
754
  inputSchema: {
689
755
  type: "object",
756
+ required: ["type"],
690
757
  properties: {
691
758
  site: { type: "string" },
692
- type: { type: "string", description: "Content type (default: article)" },
759
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
693
760
  sampleSize: { type: "number", default: 100 },
694
761
  hardThreshold: { type: "number", default: 30, description: "Flag content scoring below this" },
695
762
  },
@@ -700,9 +767,10 @@ export const definitions = [
700
767
  description: "Find published pages with no inbound internal links from other sampled pages — content islands. Best-effort over the sampled set.",
701
768
  inputSchema: {
702
769
  type: "object",
770
+ required: ["type"],
703
771
  properties: {
704
772
  site: { type: "string" },
705
- type: { type: "string", description: "Content type (default: article)" },
773
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
706
774
  sampleSize: { type: "number", default: 200 },
707
775
  },
708
776
  },
@@ -712,9 +780,10 @@ export const definitions = [
712
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.",
713
781
  inputSchema: {
714
782
  type: "object",
783
+ required: ["type"],
715
784
  properties: {
716
785
  site: { type: "string" },
717
- type: { type: "string", description: "Content type (default: article)" },
786
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
718
787
  sampleSize: { type: "number", default: 100 },
719
788
  kinds: { type: "array", items: { type: "string", enum: ["email", "ssn", "phone"] }, description: "PII kinds to scan (default: all)" },
720
789
  },
@@ -725,9 +794,10 @@ export const definitions = [
725
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.",
726
795
  inputSchema: {
727
796
  type: "object",
797
+ required: ["type"],
728
798
  properties: {
729
799
  site: { type: "string" },
730
- type: { type: "string", description: "Content type (default: article)" },
800
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
731
801
  fields: { type: "array", items: { type: "string" }, description: "Meta field machine names to check" },
732
802
  sampleSize: { type: "number", default: 100 },
733
803
  },
@@ -13,7 +13,7 @@
13
13
  import { getSiteConfig } from "../lib/config.js";
14
14
  import { resolveBackend } from "../lib/backends/index.js";
15
15
  import { resolveSecurityConfig, assertReadAllowed, assertEntityTypeAllowed } from "../lib/security.js";
16
- import { collectEntities, fieldPresence } from "../lib/reports-support.js";
16
+ import { collectEntities, fieldPresence, requireContentType, readableNodeBundles } from "../lib/reports-support.js";
17
17
  import { httpStatusOf } from "../lib/error-status.js";
18
18
 
19
19
  /** Author base fields that only ever point at `user`. */
@@ -100,11 +100,14 @@ function refsOf(rel) {
100
100
  // ---------------------------------------------------------------------------
101
101
 
102
102
  /**
103
- * Unpublished / draft content for a content type.
103
+ * Unpublished / draft content. With `type`, reads that bundle. Without it,
104
+ * reads every node bundle the connector may read, merges by `changed`
105
+ * (newest first) and keeps `limit` findings; a bundle that fails to load is
106
+ * listed in `bundleErrors` (#403).
104
107
  *
105
- * @param {object} args - { site?, type?, limit? }. `type` defaults to "article".
106
- * @returns {Promise<{contentType: string, approximate: boolean,
107
- * totalUnpublished: number, findings: object[]}>} Matching unpublished nodes.
108
+ * @param {object} args - { site?, type?, limit? }.
109
+ * @returns {Promise<{contentType: ?string, approximate: boolean,
110
+ * totalUnpublished: number, findings: object[], bundleErrors?: object[]}>} Matching unpublished nodes.
108
111
  * @throws {SecurityError} If reading the content type is not permitted.
109
112
  */
110
113
  async function unpublished({ site: siteName, type, limit = 50 }) {
@@ -112,24 +115,45 @@ async function unpublished({ site: siteName, type, limit = 50 }) {
112
115
  const sec = resolveSecurityConfig(site);
113
116
  assertReadAllowed(sec, "node", type);
114
117
  const backend = await resolveBackend(site);
115
- const contentType = type || "article";
116
- const res = await backend.listEntities({
117
- entityType: "node", bundle: contentType,
118
- filters: [{ field: "status", op: "eq", value: false }],
119
- sort: [{ field: "changed", dir: "desc" }],
120
- page: { limit },
121
- });
118
+ const bundles = type ? [type] : await readableNodeBundles(backend, sec);
119
+ const findings = [];
120
+ const bundleErrors = [];
121
+ let approximate = false;
122
+ let totalUnpublished = 0;
123
+ for (const bundle of bundles) {
124
+ let res;
125
+ try {
126
+ res = await backend.listEntities({
127
+ entityType: "node", bundle,
128
+ filters: [{ field: "status", op: "eq", value: false }],
129
+ sort: [{ field: "changed", dir: "desc" }],
130
+ page: { limit },
131
+ });
132
+ } catch (err) {
133
+ if (type) throw err;
134
+ bundleErrors.push({ contentType: bundle, error: err?.message || String(err) });
135
+ continue;
136
+ }
137
+ approximate = approximate || (res.approximate ?? false);
138
+ totalUnpublished += res.page?.total ?? res.entities.length;
139
+ for (const n of res.entities) {
140
+ findings.push({
141
+ id: n.id,
142
+ title: n.title,
143
+ status: "unpublished",
144
+ changed: n.changed,
145
+ path: n.url,
146
+ ...(type ? {} : { contentType: bundle }),
147
+ });
148
+ }
149
+ }
150
+ if (!type) findings.sort((x, y) => String(y.changed ?? "").localeCompare(String(x.changed ?? "")));
122
151
  return {
123
- contentType,
124
- approximate: res.approximate ?? false,
125
- totalUnpublished: res.page?.total ?? res.entities.length,
126
- findings: res.entities.map((n) => ({
127
- id: n.id,
128
- title: n.title,
129
- status: "unpublished",
130
- changed: n.changed,
131
- path: n.url,
132
- })),
152
+ contentType: type || null,
153
+ approximate,
154
+ totalUnpublished,
155
+ findings: findings.slice(0, limit),
156
+ ...(bundleErrors.length ? { bundleErrors } : {}),
133
157
  };
134
158
  }
135
159
 
@@ -153,12 +177,12 @@ async function unpublished({ site: siteName, type, limit = 50 }) {
153
177
  * @throws {SecurityError} If reading the content type is not permitted.
154
178
  */
155
179
  async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
180
+ const contentType = requireContentType(type, "drupal_report_missing_field");
156
181
  const site = getSiteConfig(siteName);
157
182
  const sec = resolveSecurityConfig(site);
158
- assertReadAllowed(sec, "node", type);
183
+ assertReadAllowed(sec, "node", contentType);
159
184
  if (!field) throw new Error("missingField requires a field machine name.");
160
185
  const backend = await resolveBackend(site);
161
- const contentType = type || "article";
162
186
  const entities = await collectEntities(
163
187
  backend,
164
188
  { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
@@ -213,16 +237,16 @@ async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
213
237
  * orphan; 401/403 and connector policy denials are unverifiable (#205).
214
238
  * Sampling-bounded, so `approximate` is set when the entity scan is capped.
215
239
  *
216
- * @param {object} args - { site?, type?, sampleSize? }. `type` defaults to "article".
240
+ * @param {object} args - { site?, type, sampleSize? }. `type` is required.
217
241
  * @returns {Promise<object>} Orphaned-reference findings plus scan metadata.
218
242
  * @throws {SecurityError} If reading the content type is not permitted.
219
243
  */
220
244
  async function orphanedReferences({ site: siteName, type, sampleSize = 50 }) {
245
+ const contentType = requireContentType(type, "drupal_report_orphaned_references");
221
246
  const site = getSiteConfig(siteName);
222
247
  const sec = resolveSecurityConfig(site);
223
- assertReadAllowed(sec, "node", type);
248
+ assertReadAllowed(sec, "node", contentType);
224
249
  const backend = await resolveBackend(site);
225
- const contentType = type || "article";
226
250
  const entities = await collectEntities(
227
251
  backend,
228
252
  { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
@@ -331,7 +355,7 @@ export const definitions = [
331
355
  type: "object",
332
356
  properties: {
333
357
  site: { type: "string" },
334
- type: { type: "string", description: "Content type machine name (default: article)" },
358
+ type: { type: "string", description: "Content type machine name. Omit to read every node bundle (results merged, newest first; unreadable bundles listed in bundleErrors)." },
335
359
  limit: { type: "number", default: 50, description: "Max unpublished nodes to return" },
336
360
  },
337
361
  },
@@ -340,10 +364,10 @@ export const definitions = [
340
364
  name: "drupal_report_missing_field",
341
365
  description: "Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped. An absent field is not an empty one: JSON:API leaves out a field this account may not view. When the field is absent from every sampled entity the result is `notVisible: true` with no findings (the field may be denied to this account, not exist on the bundle, or be misspelled) instead of every entity counted as missing. Otherwise each finding has `reason` 'empty' (key present, no value) or 'absent' (key missing, possibly access-denied).",
342
366
  inputSchema: {
343
- type: "object", required: ["field"],
367
+ type: "object", required: ["field", "type"],
344
368
  properties: {
345
369
  site: { type: "string" },
346
- type: { type: "string", description: "Content type machine name (default: article)" },
370
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
347
371
  field: { type: "string", description: "Field machine name to check for emptiness, e.g. 'field_meta_description', 'field_image'" },
348
372
  sampleSize: { type: "number", default: 100, description: "Max entities to scan" },
349
373
  },
@@ -353,10 +377,10 @@ export const definitions = [
353
377
  name: "drupal_report_orphaned_references",
354
378
  description: "Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. A 404 (or unaddressable ref) is an orphan; 401/403 and connector policy denials are counted as unverifiable, not missing. uid/revision_uid are skipped when the policy denies user. Flags 'approximate' when sampling-bounded.",
355
379
  inputSchema: {
356
- type: "object",
380
+ type: "object", required: ["type"],
357
381
  properties: {
358
382
  site: { type: "string" },
359
- type: { type: "string", description: "Content type machine name to scan (default: article)" },
383
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
360
384
  sampleSize: { type: "number", default: 50, description: "Max entities to scan for broken references" },
361
385
  },
362
386
  },
@@ -18,7 +18,7 @@
18
18
  import { getSiteConfig } from "../lib/config.js";
19
19
  import { resolveBackend } from "../lib/backends/index.js";
20
20
  import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
21
- import { collectEntities, gatedReport, fieldValue } from "../lib/reports-support.js";
21
+ import { collectEntities, gatedReport, fieldValue, requireContentType } from "../lib/reports-support.js";
22
22
  import { runPrivileged } from "../lib/audit-sources.js";
23
23
  import { sshDrush, parseDrush } from "./drush.js";
24
24
  import {
@@ -258,11 +258,11 @@ async function redirectHealth({ site: siteName, limit = 1000 }) {
258
258
  * @returns {Promise<object>} Link inventory plus optional live results.
259
259
  */
260
260
  async function brokenLinks({ site: siteName, type, sampleSize = 100, checkLive = false, includeExternal = false }) {
261
+ const contentType = requireContentType(type, "drupal_report_broken_links");
261
262
  const site = getSiteConfig(siteName);
262
263
  const sec = resolveSecurityConfig(site);
263
- assertReadAllowed(sec, "node", type);
264
+ assertReadAllowed(sec, "node", contentType);
264
265
  const backend = await resolveBackend(site);
265
- const contentType = type || "article";
266
266
  const baseUrl = site.baseUrl;
267
267
  const baseHost = hostOf(baseUrl);
268
268
 
@@ -345,11 +345,11 @@ async function brokenLinks({ site: siteName, type, sampleSize = 100, checkLive =
345
345
  * @returns {Promise<object>} Alias-coverage findings.
346
346
  */
347
347
  async function aliasCoverage({ site: siteName, type, sampleSize = 200 }) {
348
+ const contentType = requireContentType(type, "drupal_report_alias_coverage");
348
349
  const site = getSiteConfig(siteName);
349
350
  const sec = resolveSecurityConfig(site);
350
- assertReadAllowed(sec, "node", type);
351
+ assertReadAllowed(sec, "node", contentType);
351
352
  const backend = await resolveBackend(site);
352
- const contentType = type || "article";
353
353
 
354
354
  const nodes = await collectEntities(
355
355
  backend,
@@ -463,11 +463,11 @@ async function menuIntegrity({ site: siteName, limit = 1000 }) {
463
463
  * @returns {Promise<object>} Embed inventory and any malformed embeds.
464
464
  */
465
465
  async function brokenEmbeds({ site: siteName, type, sampleSize = 100 }) {
466
+ const contentType = requireContentType(type, "drupal_report_broken_embeds");
466
467
  const site = getSiteConfig(siteName);
467
468
  const sec = resolveSecurityConfig(site);
468
- assertReadAllowed(sec, "node", type);
469
+ assertReadAllowed(sec, "node", contentType);
469
470
  const backend = await resolveBackend(site);
470
- const contentType = type || "article";
471
471
 
472
472
  const entities = await collectEntities(
473
473
  backend,
@@ -538,10 +538,10 @@ export const definitions = [
538
538
  name: "drupal_report_broken_links",
539
539
  description: "Inventory links in published body content (internal/external/images), aggregate external hosts, and flag malformed hrefs. With checkLive:true, verifies links via bounded, SSRF-guarded outbound HTTP (internal always; external only if includeExternal and host-allowlisted). No network egress unless checkLive is set.",
540
540
  inputSchema: {
541
- type: "object",
541
+ type: "object", required: ["type"],
542
542
  properties: {
543
543
  site: { type: "string" },
544
- type: { type: "string", description: "Content type (default: article)" },
544
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
545
545
  sampleSize: { type: "number", default: 100, description: "Max nodes to scan" },
546
546
  checkLive: { type: "boolean", default: false, description: "Perform live HTTP checks (off by default)" },
547
547
  includeExternal: { type: "boolean", default: false, description: "When checkLive, also check allowlisted external hosts" },
@@ -552,10 +552,10 @@ export const definitions = [
552
552
  name: "drupal_report_alias_coverage",
553
553
  description: "URL-alias coverage for a content type: nodes whose canonical URL is still /node/N (no alias / pathauto gap), plus conflicting aliases (one alias mapped to multiple system paths) when the path_alias entity is exposed.",
554
554
  inputSchema: {
555
- type: "object",
555
+ type: "object", required: ["type"],
556
556
  properties: {
557
557
  site: { type: "string" },
558
- type: { type: "string", description: "Content type (default: article)" },
558
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
559
559
  sampleSize: { type: "number", default: 200, description: "Max nodes to scan" },
560
560
  },
561
561
  },
@@ -575,10 +575,10 @@ export const definitions = [
575
575
  name: "drupal_report_broken_embeds",
576
576
  description: "Scan published body content for embedded entities (media/entity embeds) and report usage by type, flagging embeds with a missing/malformed data-entity-uuid. Full target-existence verification is gated on a future server-tool.",
577
577
  inputSchema: {
578
- type: "object",
578
+ type: "object", required: ["type"],
579
579
  properties: {
580
580
  site: { type: "string" },
581
- type: { type: "string", description: "Content type (default: article)" },
581
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
582
582
  sampleSize: { type: "number", default: 100, description: "Max nodes to scan" },
583
583
  },
584
584
  },