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
@@ -11,7 +11,7 @@
11
11
  import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
13
  import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
14
- import { collectEntities, gatedReport, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince } from "../lib/reports-support.js";
14
+ import { collectEntities, gatedReport, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince, requireContentType, readableNodeBundles } from "../lib/reports-support.js";
15
15
  import { fetchRenderedMetaDescriptions } from "../lib/metatag-audit.js";
16
16
 
17
17
  // ---------------------------------------------------------------------------
@@ -68,11 +68,11 @@ async function contentSummary({ site: siteName }) {
68
68
  * @throws {SecurityError} If reading the content type is not permitted.
69
69
  */
70
70
  async function staleContent({ site: siteName, type, days = 180, status, limit = 50 }) {
71
+ const contentType = requireContentType(type, "drupal_report_stale_content");
71
72
  const site = getSiteConfig(siteName);
72
73
  const sec = resolveSecurityConfig(site);
73
- assertReadAllowed(sec, "node", type);
74
+ assertReadAllowed(sec, "node", contentType);
74
75
  const backend = await resolveBackend(site);
75
- const contentType = type || "article";
76
76
  // `changed` is an integer Unix timestamp (seconds). Filter with epoch seconds,
77
77
  // not an ISO string — PostgreSQL rejects the string against an integer column.
78
78
  const cutoffMs = Date.now() - days * 86400000;
@@ -112,11 +112,11 @@ async function staleContent({ site: siteName, type, days = 180, status, limit =
112
112
  * @throws {SecurityError} If reading the content type is not permitted.
113
113
  */
114
114
  async function contentByAuthor({ site: siteName, type, limit = 100 }) {
115
+ const contentType = requireContentType(type, "drupal_report_content_by_author");
115
116
  const site = getSiteConfig(siteName);
116
117
  const sec = resolveSecurityConfig(site);
117
- assertReadAllowed(sec, "node", type);
118
+ assertReadAllowed(sec, "node", contentType);
118
119
  const backend = await resolveBackend(site);
119
- const contentType = type || "article";
120
120
  const entities = await collectEntities(
121
121
  backend,
122
122
  { entityType: "node", bundle: contentType, include: ["uid"] },
@@ -141,7 +141,10 @@ async function contentByAuthor({ site: siteName, type, limit = 100 }) {
141
141
  }
142
142
 
143
143
  /**
144
- * Recently published content of a given type, newest first.
144
+ * Recently published content, newest first. With `type`, reads that bundle.
145
+ * Without it, reads every node bundle the connector may read, merges by
146
+ * `created`, and keeps the newest `limit`; a bundle that fails to load is
147
+ * listed in `bundleErrors` (#403).
145
148
  *
146
149
  * @param {object} args - { site?, type?, limit? }.
147
150
  * @returns {Promise<object>} The most recently created published nodes.
@@ -152,22 +155,42 @@ async function recentlyPublished({ site: siteName, type, limit = 20 }) {
152
155
  const sec = resolveSecurityConfig(site);
153
156
  assertReadAllowed(sec, "node", type);
154
157
  const backend = await resolveBackend(site);
155
- const res = await backend.listEntities({
156
- entityType: "node", bundle: type || "article",
157
- filters: [{ field: "status", op: "eq", value: true }],
158
- sort: [{ field: "created", dir: "desc" }],
159
- page: { limit },
160
- });
158
+ const bundles = type ? [type] : await readableNodeBundles(backend, sec);
159
+ const nodes = [];
160
+ const bundleErrors = [];
161
+ let approximate = false;
162
+ for (const bundle of bundles) {
163
+ let res;
164
+ try {
165
+ res = await backend.listEntities({
166
+ entityType: "node", bundle,
167
+ filters: [{ field: "status", op: "eq", value: true }],
168
+ sort: [{ field: "created", dir: "desc" }],
169
+ page: { limit },
170
+ });
171
+ } catch (err) {
172
+ if (type) throw err;
173
+ bundleErrors.push({ contentType: bundle, error: err?.message || String(err) });
174
+ continue;
175
+ }
176
+ approximate = approximate || (res.approximate ?? false);
177
+ for (const n of res.entities) {
178
+ nodes.push({
179
+ id: n.id,
180
+ title: n.title,
181
+ created: n.created,
182
+ changed: n.changed,
183
+ path: n.url,
184
+ ...(type ? {} : { contentType: bundle }),
185
+ });
186
+ }
187
+ }
188
+ if (!type) nodes.sort((x, y) => String(y.created ?? "").localeCompare(String(x.created ?? "")));
161
189
  return {
162
- contentType: type || "article",
163
- approximate: res.approximate ?? false,
164
- nodes: res.entities.map((n) => ({
165
- id: n.id,
166
- title: n.title,
167
- created: n.created,
168
- changed: n.changed,
169
- path: n.url,
170
- })),
190
+ contentType: type || null,
191
+ approximate,
192
+ nodes: nodes.slice(0, limit),
193
+ ...(bundleErrors.length ? { bundleErrors } : {}),
171
194
  };
172
195
  }
173
196
 
@@ -255,12 +278,13 @@ async function fieldCompleteness({ site: siteName, type, fields, sampleSize = 10
255
278
  * Per-term counts that error out are reported as null nodeCount.
256
279
  *
257
280
  * @param {object} args - { site?, vocabulary, contentType?, referenceField?, limit? }.
258
- * `referenceField` defaults to `field_{vocabulary}`; `contentType` to "article".
281
+ * `referenceField` defaults to `field_{vocabulary}`; `contentType` is required.
259
282
  * @returns {Promise<object>} Terms sorted by usage, plus an unused-term count.
260
283
  * @throws {Error} If no vocabulary is supplied.
261
284
  * @throws {SecurityError} If reading the vocabulary is not permitted.
262
285
  */
263
286
  async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenceField, limit = 100 }) {
287
+ const ctype = requireContentType(contentType, "drupal_report_taxonomy_usage", "contentType");
264
288
  const site = getSiteConfig(siteName);
265
289
  const sec = resolveSecurityConfig(site);
266
290
  assertReadAllowed(sec, "taxonomy_term", vocabulary);
@@ -272,7 +296,6 @@ async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenc
272
296
  limit
273
297
  );
274
298
  const field = referenceField || `field_${vocabulary}`;
275
- const ctype = contentType || "article";
276
299
  let approximate = false;
277
300
  const results = await Promise.all(terms.map(async (term) => {
278
301
  try {
@@ -306,9 +329,10 @@ async function taxonomyUsage({ site: siteName, vocabulary, contentType, referenc
306
329
  * @throws {SecurityError} If reading the content type is not permitted.
307
330
  */
308
331
  async function revisionHotspots({ site: siteName, type, limit = 20 }) {
332
+ const contentType = requireContentType(type, "drupal_report_revision_hotspots");
309
333
  const site = getSiteConfig(siteName);
310
334
  const sec = resolveSecurityConfig(site);
311
- assertReadAllowed(sec, "node", type);
335
+ assertReadAllowed(sec, "node", contentType);
312
336
  const backend = await resolveBackend(site);
313
337
  if (!backend.capabilities().revisions) {
314
338
  return gatedReport(
@@ -317,7 +341,6 @@ async function revisionHotspots({ site: siteName, type, limit = 20 }) {
317
341
  "This backend does not expose entity revisions. Use a JSON:API site."
318
342
  );
319
343
  }
320
- const contentType = type || "article";
321
344
  const recent = await backend.listEntities({
322
345
  entityType: "node", bundle: contentType,
323
346
  sort: [{ field: "changed", dir: "desc" }],
@@ -431,11 +454,11 @@ async function userActivity({ site: siteName, inactiveDays = 90, limit = 50 }) {
431
454
  * @throws {SecurityError} If reading the content type is not permitted.
432
455
  */
433
456
  async function seoAudit({ site: siteName, type, sampleSize = 100 }) {
457
+ const contentType = requireContentType(type, "drupal_report_seo_audit");
434
458
  const site = getSiteConfig(siteName);
435
459
  const sec = resolveSecurityConfig(site);
436
- assertReadAllowed(sec, "node", type);
460
+ assertReadAllowed(sec, "node", contentType);
437
461
  const backend = await resolveBackend(site);
438
- const contentType = type || "article";
439
462
  const issueKeys = ["missingMetaDescription", "titleTooLong", "titleTooShort", "thinContent"];
440
463
  const issues = new Map(issueKeys.map((k) => [k, []]));
441
464
  const entities = await collectEntities(
@@ -524,11 +547,11 @@ function resolveJsonapiMetaSource(entities) {
524
547
  * @throws {SecurityError} If reading the content type is not permitted.
525
548
  */
526
549
  async function accessibilityAudit({ site: siteName, type, sampleSize = 100 }) {
550
+ const contentType = requireContentType(type, "drupal_report_accessibility_audit");
527
551
  const site = getSiteConfig(siteName);
528
552
  const sec = resolveSecurityConfig(site);
529
- assertReadAllowed(sec, "node", type);
553
+ assertReadAllowed(sec, "node", contentType);
530
554
  const backend = await resolveBackend(site);
531
- const contentType = type || "article";
532
555
  const issueKeys = ["imagesWithoutAlt", "inlineH1", "nonDescriptiveLinkText", "tablesWithoutCaption"];
533
556
  const issues = new Map(issueKeys.map((k) => [k, []]));
534
557
  const badLinkText = />\s*(click here|read more|learn more|here|more|link)\s*</i;
@@ -572,10 +595,10 @@ export const definitions = [
572
595
  name: "drupal_report_stale_content",
573
596
  description: "Find content that hasn't been updated in N days. Returns a sorted list with titles, status, and days-since-update.",
574
597
  inputSchema: {
575
- type: "object",
598
+ type: "object", required: ["type"],
576
599
  properties: {
577
600
  site: { type: "string" },
578
- type: { type: "string", description: "Content type (default: article)" },
601
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
579
602
  days: { type: "number", default: 180, description: "Stale threshold in days" },
580
603
  status: { type: "boolean", description: "Filter by publish status" },
581
604
  limit: { type: "number", default: 50 },
@@ -586,10 +609,10 @@ export const definitions = [
586
609
  name: "drupal_report_content_by_author",
587
610
  description: "Count nodes per author for a given content type. Returns author UUIDs and counts sorted by most prolific.",
588
611
  inputSchema: {
589
- type: "object",
612
+ type: "object", required: ["type"],
590
613
  properties: {
591
614
  site: { type: "string" },
592
- type: { type: "string", description: "Content type (default: article)" },
615
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
593
616
  limit: { type: "number", default: 100, description: "Max nodes to scan" },
594
617
  },
595
618
  },
@@ -601,7 +624,7 @@ export const definitions = [
601
624
  type: "object",
602
625
  properties: {
603
626
  site: { type: "string" },
604
- type: { type: "string", description: "Content type (default: article)" },
627
+ type: { type: "string", description: "Content type machine name. Omit to read every node bundle (results merged, newest first; unreadable bundles listed in bundleErrors)." },
605
628
  limit: { type: "number", default: 20 },
606
629
  },
607
630
  },
@@ -623,11 +646,11 @@ export const definitions = [
623
646
  name: "drupal_report_taxonomy_usage",
624
647
  description: "Count how many nodes use each term in a vocabulary. Identifies over-used, under-used, and orphaned terms.",
625
648
  inputSchema: {
626
- type: "object", required: ["vocabulary"],
649
+ type: "object", required: ["vocabulary", "contentType"],
627
650
  properties: {
628
651
  site: { type: "string" },
629
652
  vocabulary: { type: "string", description: "Vocabulary machine name, e.g. 'tags', 'category'" },
630
- contentType: { type: "string", description: "Content type to count references from (default: article)" },
653
+ contentType: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
631
654
  referenceField: { type: "string", description: "Field referencing the vocabulary (default: field_{vocabulary})" },
632
655
  limit: { type: "number", default: 100 },
633
656
  },
@@ -637,10 +660,10 @@ export const definitions = [
637
660
  name: "drupal_report_revision_hotspots",
638
661
  description: "Find nodes with the most revision activity — useful for spotting churn or content that needs editorial process review. Requires Drupal 9.3+ JSON:API revisions.",
639
662
  inputSchema: {
640
- type: "object",
663
+ type: "object", required: ["type"],
641
664
  properties: {
642
665
  site: { type: "string" },
643
- type: { type: "string", description: "Content type (default: article)" },
666
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
644
667
  limit: { type: "number", default: 20 },
645
668
  },
646
669
  },
@@ -661,10 +684,10 @@ export const definitions = [
661
684
  name: "drupal_report_seo_audit",
662
685
  description: "SEO audit for a content type: missing meta descriptions, title length issues, and thin content (under 300 words). Returns node lists for each issue category. Meta descriptions use the rendered Metatag output via GraphQL when available (reported as `metaSource`); when no description source is readable it reports the meta check as unavailable rather than a false zero.",
663
686
  inputSchema: {
664
- type: "object",
687
+ type: "object", required: ["type"],
665
688
  properties: {
666
689
  site: { type: "string" },
667
- type: { type: "string", description: "Content type (default: article)" },
690
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
668
691
  sampleSize: { type: "number", default: 100 },
669
692
  },
670
693
  },
@@ -673,10 +696,10 @@ export const definitions = [
673
696
  name: "drupal_report_accessibility_audit",
674
697
  description: "Accessibility audit for body content: images without alt text, inline H1 tags, non-descriptive link text ('click here', 'read more'), and tables without captions.",
675
698
  inputSchema: {
676
- type: "object",
699
+ type: "object", required: ["type"],
677
700
  properties: {
678
701
  site: { type: "string" },
679
- type: { type: "string", description: "Content type (default: article)" },
702
+ type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
680
703
  sampleSize: { type: "number", default: 100 },
681
704
  },
682
705
  },
@@ -25,6 +25,7 @@ import {
25
25
  } from "../lib/security.js";
26
26
  import {
27
27
  assertDraftLangcode, readDraftTranslation, readNodeDraftInventory, readTranslationInventory,
28
+ isUnpublishedWorkingDraft,
28
29
  } from "../lib/sentinel-draft.js";
29
30
  import { omitLiveComputedMetatag } from "../lib/entity-response.js";
30
31
  import { changedAheadOfRevision, prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
@@ -221,7 +222,7 @@ async function getRevision({ site: siteName, type, id, version, langcode }) {
221
222
  const targetLang = assertDraftLangcode(langcode);
222
223
  const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
223
224
  const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
224
- if (!inventory.live?.vid || !inventory.working?.vid || !workingRow || workingRow.status !== false) {
225
+ if (!inventory.live?.vid || !inventory.working?.vid || !workingRow || !isUnpublishedWorkingDraft(workingRow)) {
225
226
  throw new Error("No unpublished working translation is addressable for this revision.");
226
227
  }
227
228
  const entity = await readDraftTranslation(backend, {
@@ -6,32 +6,81 @@
6
6
  * the schedule on two entity fields — `publish_on` and `unpublish_on` — which the
7
7
  * module adds to a content type when scheduling is enabled for that bundle.
8
8
  *
9
+ * On a bundle under Content Moderation, Scheduler Content Moderation
10
+ * Integration also needs `publish_state` / `unpublish_state`. Dates without
11
+ * those states are accepted by Drupal and then fail at cron (#402).
12
+ *
9
13
  * This is a thin write over the canonical backend's updateEntity: the supplied
10
- * timestamps are passed straight through as those two attributes. Scheduler
14
+ * timestamps are passed straight through as those attributes. Scheduler
11
15
  * accepts an ISO 8601 datetime string or a Unix epoch; we accept either form and
12
16
  * forward it unchanged (the JSON:API backend coerces datetime fields as needed).
13
17
  *
14
18
  * Capability degradation: if the Scheduler module is not installed, or the bundle
15
- * does not have the publish_on / unpublish_on fields, the backend write fails with
16
- * an "unknown field" error. We catch that and re-throw a clear, actionable message
17
- * (while still surfacing the underlying backend error) rather than a raw stack.
19
+ * does not have the publish_on / unpublish_on fields, JSON:API rejects the write
20
+ * because the attribute does not exist. We catch that and re-throw a clear,
21
+ * actionable message (while still surfacing the underlying backend error).
22
+ *
23
+ * The connector does not decide who may schedule which transition. Drupal's
24
+ * validation (SchedulerModerationTransitionAccess) and the site's MCP Sentinel
25
+ * policy are the gate; their refusals are surfaced verbatim. The only local
26
+ * refusal is a date with no matching state on a bundle that exposes the state
27
+ * field, because Drupal accepts that write and the schedule then never runs.
18
28
  */
19
29
 
20
30
  import { getSiteConfig } from "../lib/config.js";
21
31
  import { resolveBackend } from "../lib/backends/index.js";
22
32
  import { resolveSecurityConfig, assertWriteAllowed, redactCanonicalEntity } from "../lib/security.js";
33
+ import { entityLooksModerated } from "../lib/moderation-default.js";
34
+ import { fieldPresence } from "../lib/reports-support.js";
35
+ import { httpStatusOf } from "../lib/error-status.js";
23
36
 
24
37
  /**
25
- * Heuristic: does this backend error look like a missing/unknown field? Scheduler
26
- * fields are absent unless the module is installed and enabled for the bundle, so
27
- * an unknown-field error almost always means a missing Scheduler capability.
38
+ * Does this backend error say a Scheduler attribute does not exist on the
39
+ * bundle? Only Drupal JSON:API's explicit form counts ("The attribute
40
+ * publish_on does not exist on the node--page resource type."). A 422
41
+ * validation detail names the field too (`publish_state: …`) and may say
42
+ * "invalid" or "unknown"; that is a refusal by the site, surfaced verbatim,
43
+ * not a missing capability (#402).
28
44
  *
29
45
  * @param {Error} err - The error thrown by the backend write.
30
46
  * @returns {boolean}
31
47
  */
32
48
  export function looksLikeUnknownField(err) {
33
- const msg = String(err?.message || err || "");
34
- return /unknown|not exist|no field|invalid field|unrecognized|publish_on|unpublish_on/i.test(msg);
49
+ const msg = String(err?.message || err || "").slice(0, 2000);
50
+ return /\battribute [\w.-]{1,128} does not exist on the [\w.-]{1,128} resource type\b/i.test(msg);
51
+ }
52
+
53
+ /**
54
+ * Wrap a site refusal so the reason is the first thing the caller reads. The
55
+ * original message (with Drupal's or Sentinel's reason) is kept verbatim and
56
+ * the HTTP status is carried over.
57
+ *
58
+ * @param {Error} err - The error thrown by the backend write.
59
+ * @param {string} type - Bundle machine name.
60
+ * @returns {Error}
61
+ */
62
+ function siteRefusal(err, type) {
63
+ const status = httpStatusOf(err);
64
+ const reason = err?.message || String(err);
65
+ const out = new Error(
66
+ `The site refused the schedule on '${type}'${status ? ` (HTTP ${status})` : ""}. ` +
67
+ `Drupal validation or the site's MCP Sentinel policy decides scheduled transitions; the connector does not. Reason: ${reason}`,
68
+ );
69
+ if (status) out.status = status;
70
+ out.cause = err;
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Whether a schema or entity exposes a Scheduler attribute.
76
+ * @param {?object} entity Canonical entity.
77
+ * @param {?object} schema Bundle schema from getEntitySchema.
78
+ * @param {string} field Field machine name.
79
+ * @returns {boolean}
80
+ */
81
+ function schedulerFieldPresent(entity, schema, field) {
82
+ if (entity && fieldPresence(entity, field).present) return true;
83
+ return Boolean(schema?.attributes && Object.prototype.hasOwnProperty.call(schema.attributes, field));
35
84
  }
36
85
 
37
86
  /**
@@ -41,12 +90,21 @@ export function looksLikeUnknownField(err) {
41
90
  * canonical updateEntity. At least one of publishOn / unpublishOn must be given.
42
91
  * Each value is forwarded unchanged (ISO 8601 string or epoch integer).
43
92
  *
44
- * @param {object} args - { site?, type, id, publishOn?, unpublishOn? }.
45
- * @returns {Promise<object>} The redacted, updated node descriptor.
46
- * @throws {Error} If neither timestamp is supplied, if the policy forbids the
47
- * write, or — degraded — if the Scheduler fields are unknown on the bundle.
93
+ * `publishState` / `unpublishState` are written as `publish_state` /
94
+ * `unpublish_state`. When the bundle exposes the matching state field, a date
95
+ * without its state is refused locally (#402); every other decision is Drupal's.
96
+ *
97
+ * @param {object} args - { site?, type, id, publishOn?, unpublishOn?, publishState?, unpublishState? }.
98
+ * @returns {Promise<object>} The redacted, updated node descriptor, with
99
+ * `warnings` when a moderated bundle has no state field to pair a date with.
100
+ * @throws {Error} If neither timestamp is supplied, if connector policy forbids
101
+ * the write, if a date is missing its state on a bundle with the state field,
102
+ * if the site refuses the write (reason kept verbatim), or — degraded — if the
103
+ * Scheduler fields are unknown on the bundle.
48
104
  */
49
- async function schedulePublish({ site: siteName, type, id, publishOn, unpublishOn }) {
105
+ async function schedulePublish({
106
+ site: siteName, type, id, publishOn, unpublishOn, publishState, unpublishState,
107
+ }) {
50
108
  if (publishOn === undefined && unpublishOn === undefined) {
51
109
  throw new Error("Provide at least one of publishOn or unpublishOn to schedule the node.");
52
110
  }
@@ -55,9 +113,53 @@ async function schedulePublish({ site: siteName, type, id, publishOn, unpublishO
55
113
  assertWriteAllowed(sec, "update", "node", type);
56
114
  const backend = await resolveBackend(site);
57
115
 
116
+ let existing = null;
117
+ if (typeof backend.getEntity === "function") {
118
+ try {
119
+ existing = await backend.getEntity({ entityType: "node", bundle: type, id }) ?? null;
120
+ } catch {
121
+ existing = null;
122
+ }
123
+ }
124
+ let schema = null;
125
+ if (existing && typeof backend.getEntitySchema === "function") {
126
+ try {
127
+ schema = await backend.getEntitySchema("node", type);
128
+ } catch {
129
+ schema = null;
130
+ }
131
+ }
132
+
133
+ // A state field on the bundle means Scheduler Content Moderation Integration
134
+ // is active there. Drupal accepts a date with no state, and the schedule then
135
+ // fails at every cron run, so that one case is refused here (#402).
136
+ const pairs = [
137
+ ["publishOn", publishOn, "publishState", publishState, "publish_state", "published"],
138
+ ["unpublishOn", unpublishOn, "unpublishState", unpublishState, "unpublish_state", "archived"],
139
+ ];
140
+ const warnings = [];
141
+ for (const [dateArg, date, stateArg, state, stateField, example] of pairs) {
142
+ if (date === undefined || state !== undefined) continue;
143
+ if (schedulerFieldPresent(existing, schema, stateField)) {
144
+ throw new Error(
145
+ `'${type}' is a moderated bundle with Scheduler Content Moderation Integration: pass ${stateArg} ` +
146
+ `(the moderation state to enter at ${dateArg}, e.g. '${example}'). A schedule without a state never runs: ` +
147
+ "Drupal accepts it, then cron logs 'Publishing failed' on every run.",
148
+ );
149
+ }
150
+ if (entityLooksModerated(existing)) {
151
+ warnings.push(
152
+ `'${type}' is moderated but exposes no ${stateField} field. Scheduler cannot change a moderated node ` +
153
+ `without the Scheduler Content Moderation Integration module and a ${stateArg}, so this ${dateArg} may never take effect.`,
154
+ );
155
+ }
156
+ }
157
+
58
158
  const attributes = {};
59
159
  if (publishOn !== undefined) attributes.publish_on = publishOn;
60
160
  if (unpublishOn !== undefined) attributes.unpublish_on = unpublishOn;
161
+ if (publishState !== undefined) attributes.publish_state = publishState;
162
+ if (unpublishState !== undefined) attributes.unpublish_state = unpublishState;
61
163
 
62
164
  let updated;
63
165
  try {
@@ -65,14 +167,20 @@ async function schedulePublish({ site: siteName, type, id, publishOn, unpublishO
65
167
  } catch (err) {
66
168
  if (looksLikeUnknownField(err)) {
67
169
  throw new Error(
68
- `Could not set Scheduler dates on '${type}': the publish_on / unpublish_on fields are not available. ` +
69
- "This tool requires the Drupal Scheduler module to be installed and enabled for this content type. " +
170
+ `Could not set Scheduler dates on '${type}': a Scheduler field is not available on this bundle. ` +
171
+ "publish_on / unpublish_on need the Drupal Scheduler module enabled for this content type; " +
172
+ "publish_state / unpublish_state also need Scheduler Content Moderation Integration. " +
70
173
  `Backend error: ${err?.message || err}`,
71
174
  );
72
175
  }
176
+ // 403 (access or Sentinel policy) and 422 (validation) are the site's
177
+ // decision on this write. Other failures pass through unchanged.
178
+ const status = httpStatusOf(err);
179
+ if (status === 403 || status === 422) throw siteRefusal(err, type);
73
180
  throw err;
74
181
  }
75
- return updated ? redactCanonicalEntity(updated, sec, "node") : updated;
182
+ const out = updated ? redactCanonicalEntity(updated, sec, "node") : updated;
183
+ return out && warnings.length ? { ...out, warnings } : out;
76
184
  }
77
185
 
78
186
  // ---------------------------------------------------------------------------
@@ -84,19 +192,26 @@ export const definitions = [
84
192
  name: "drupal_schedule_publish",
85
193
  description:
86
194
  "Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. " +
87
- "Sets the publish_on and unpublish_on fields on the node. " +
88
- "Requires the Scheduler module to be installed and enabled for the content type, with the publish_on / " +
89
- "unpublish_on fields present on the bundle — otherwise the call fails with a clear capability error. " +
90
- "Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged. " +
91
- "Provide at least one of publishOn or unpublishOn.",
195
+ "Sets the publish_on and unpublish_on fields on the node. Provide at least one of publishOn or unpublishOn. " +
196
+ "Moderated bundles (Content Moderation) also need a target state: pass publishState / unpublishState " +
197
+ "(moderation state machine names, e.g. 'published', 'archived'), written as publish_state / unpublish_state. " +
198
+ "These fields exist only when the Scheduler Content Moderation Integration module is enabled. When the bundle " +
199
+ "exposes a state field and its date is set without the state, the call fails, because Drupal would accept a " +
200
+ "schedule that never runs. When a moderated bundle has no state field, the dates are written and the result " +
201
+ "carries a warning. Whether this account may schedule a given transition is decided by the site (Drupal " +
202
+ "validation and MCP Sentinel policy), not the connector; a refusal returns the site's reason verbatim. " +
203
+ "Without the Scheduler module on the content type, the call fails with a capability error. " +
204
+ "Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged.",
92
205
  inputSchema: {
93
206
  type: "object", required: ["type", "id"],
94
207
  properties: {
95
- site: { type: "string", description: "Named site (omit for default)" },
96
- type: { type: "string", description: "Content type machine name, e.g. 'article'" },
97
- id: { type: "string", description: "Node UUID" },
98
- publishOn: { type: ["string", "number"], description: "When to publish — ISO 8601 datetime or Unix epoch. Sets the Scheduler publish_on field." },
99
- unpublishOn: { type: ["string", "number"], description: "When to unpublish — ISO 8601 datetime or Unix epoch. Sets the Scheduler unpublish_on field." },
208
+ site: { type: "string", description: "Named site (omit for default)" },
209
+ type: { type: "string", description: "Content type machine name, e.g. 'article'" },
210
+ id: { type: "string", description: "Node UUID" },
211
+ publishOn: { type: ["string", "number"], description: "When to publish — ISO 8601 datetime or Unix epoch. Sets the Scheduler publish_on field." },
212
+ unpublishOn: { type: ["string", "number"], description: "When to unpublish — ISO 8601 datetime or Unix epoch. Sets the Scheduler unpublish_on field." },
213
+ publishState: { type: "string", description: "Moderation state machine name to enter at publishOn (e.g. 'published'). Written as publish_state. Required when publishOn is set and the bundle has a publish_state field." },
214
+ unpublishState: { type: "string", description: "Moderation state machine name to enter at unpublishOn (e.g. 'archived'). Written as unpublish_state. Required when unpublishOn is set and the bundle has an unpublish_state field." },
100
215
  },
101
216
  },
102
217
  },
@@ -2,8 +2,8 @@
2
2
  * Tool group: content search.
3
3
  *
4
4
  * Search API / Solr does not expose a JSON:API surface by default, so this tool
5
- * provides a best-effort search: a JSON:API title/body CONTAINS match over a
6
- * content type. Results are flagged mode:"fallback" and the response notes that
5
+ * provides a best-effort search: a JSON:API title CONTAINS match over one
6
+ * content type, or over every node bundle when `type` is omitted (#403). Results are flagged mode:"fallback" and the response notes that
7
7
  * true relevance ranking requires a Search API endpoint (or the Drush bridge).
8
8
  * Read-only and redacted per the site policy.
9
9
  */
@@ -11,28 +11,32 @@
11
11
  import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
13
  import { resolveSecurityConfig, assertReadAllowed, redactCanonicalEntity } from "../lib/security.js";
14
+ import { readableNodeBundles, listNodesAcrossBundles } from "../lib/reports-support.js";
14
15
 
15
16
  /**
16
- * Search nodes of a type by a query string (title CONTAINS, best-effort).
17
+ * Search nodes by a query string (title CONTAINS, best-effort). With `type`,
18
+ * searches that bundle; without it, every node bundle the connector may read,
19
+ * merged newest first. A bundle that fails to load fails the search.
17
20
  * @param {object} args - { site?, query, type?, limit? }.
18
- * @returns {Promise<{query, mode, note, results}>}
21
+ * @returns {Promise<{query, type, mode, note, results}>}
19
22
  */
20
- async function search({ site: siteName, query, type = "article", limit = 10 }) {
23
+ async function search({ site: siteName, query, type, limit = 10 }) {
21
24
  if (!query || !String(query).trim()) throw new Error("A non-empty 'query' is required.");
22
25
  const site = getSiteConfig(siteName);
23
26
  const sec = resolveSecurityConfig(site);
24
27
  assertReadAllowed(sec, "node", type);
25
28
  const backend = await resolveBackend(site);
26
- const res = await backend.listEntities({
27
- entityType: "node", bundle: type,
29
+ const descriptor = {
28
30
  filters: [{ field: "title", op: "contains", value: query }],
29
31
  sort: [{ field: "changed", dir: "desc" }],
30
- page: { limit },
31
- });
32
- const results = res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
32
+ };
33
+ const entities = type
34
+ ? (await backend.listEntities({ ...descriptor, entityType: "node", bundle: type, page: { limit } })).entities
35
+ : await listNodesAcrossBundles(backend, await readableNodeBundles(backend, sec), descriptor, limit);
36
+ const results = entities.map((e) => redactCanonicalEntity(e, sec, "node"));
33
37
  return {
34
38
  query,
35
- type,
39
+ type: type || null,
36
40
  mode: "fallback",
37
41
  note: "JSON:API title CONTAINS match. Relevance-ranked full-text search requires a Search API/Solr endpoint or the Drush bridge.",
38
42
  results,
@@ -48,7 +52,7 @@ export const definitions = [
48
52
  properties: {
49
53
  site: { type: "string" },
50
54
  query: { type: "string", description: "Search term" },
51
- type: { type: "string", description: "Content type machine name (default: article)" },
55
+ 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." },
52
56
  limit: { type: "number", default: 10 },
53
57
  },
54
58
  },
@@ -45,6 +45,9 @@ import {
45
45
  supportsRevisePublishedTranslation,
46
46
  supportsReviseOverWorkingCopy,
47
47
  supportsSentinelDraft,
48
+ isUnpublishedWorkingDraft,
49
+ isCarriedPublishedLanguage,
50
+ carriedPublishedLanguageError,
48
51
  } from "../lib/sentinel-draft.js";
49
52
  import { mapTranslationRow } from "../lib/translation-rows.js";
50
53
  import { dryRunChecks, PREFLIGHT_SENTINEL_DRAFT } from "../lib/dry-run-checks.js";
@@ -179,13 +182,16 @@ async function createTranslation({
179
182
  if (hasWorking) {
180
183
  const workingRow = (draftRevision.inventory?.working?.translations ?? [])
181
184
  .find((row) => row?.langcode === targetLang);
182
- if (workingRow && workingRow.status === false) {
185
+ if (workingRow && isUnpublishedWorkingDraft(workingRow)) {
183
186
  const continuer = entityType === "media" ? "drupal_update_media" : "drupal_update_node";
184
187
  throw new Error(
185
188
  "This language is already an unpublished draft on the working copy. " +
186
189
  `Continue it with ${continuer} and langcode. Revise was not attempted.`,
187
190
  );
188
191
  }
192
+ if (workingRow && isCarriedPublishedLanguage(workingRow)) {
193
+ throw carriedPublishedLanguageError(targetLang);
194
+ }
189
195
  // Sentinel 2.25.0+ builds the draft on the named working copy; the
190
196
  // pair is sent as If-Match "live:working" by createTranslationDraft.
191
197
  if (!supportsReviseOverWorkingCopy(draftRevision)) {