drupal-mcp-connector 2.24.2 → 2.26.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.
@@ -110,7 +110,7 @@ export function normalizeWorkflow(raw) {
110
110
  * @param {string} namespace
111
111
  * @returns {string}
112
112
  */
113
- export function resolvedName(def, namespace) {
113
+ function resolvedName(def, namespace) {
114
114
  return def.name || workflowPromptName(namespace, def.id);
115
115
  }
116
116
 
@@ -141,7 +141,7 @@ export function publicToolName(namespace, alias, tools) {
141
141
  * @param {{builtin?: boolean}} [opts]
142
142
  * @returns {string[]|null} Public names, or null when any tool is missing.
143
143
  */
144
- export function resolveToolNames(def, namespace, tools, opts = {}) {
144
+ function resolveToolNames(def, namespace, tools, opts = {}) {
145
145
  const visible = new Set((tools ?? []).map((tool) => tool.name));
146
146
  const names = [];
147
147
  for (const alias of def.tools) {
@@ -270,7 +270,7 @@ export function renderWorkflowMessages(workflow, args = {}) {
270
270
  * @param {string} instructions
271
271
  * @returns {string[]}
272
272
  */
273
- export function toolAliasesInInstructions(instructions) {
273
+ function toolAliasesInInstructions(instructions) {
274
274
  const aliases = [];
275
275
  const seen = new Set();
276
276
  for (const match of String(instructions ?? "").matchAll(/\{tool:([a-z][a-z0-9_]{0,47})\}/g)) {
@@ -287,7 +287,7 @@ export function toolAliasesInInstructions(instructions) {
287
287
  * @param {object} got
288
288
  * @returns {string}
289
289
  */
290
- export function instructionTextFromDrupalPrompt(got) {
290
+ function instructionTextFromDrupalPrompt(got) {
291
291
  const messages = Array.isArray(got?.messages) ? got.messages : [];
292
292
  const parts = [];
293
293
  for (const message of messages) {
@@ -313,7 +313,7 @@ export function instructionTextFromDrupalPrompt(got) {
313
313
  * @param {object} toolsMap `serverTools.modules.tools`
314
314
  * @returns {boolean}
315
315
  */
316
- export function inferReadOnly(aliases, toolsMap) {
316
+ function inferReadOnly(aliases, toolsMap) {
317
317
  for (const alias of aliases) {
318
318
  const operation = toolsMap?.[alias]?.operation;
319
319
  if (operation === "write" || operation === "delete") return false;
@@ -18,7 +18,7 @@
18
18
  import { getSiteConfig } from "../lib/config.js";
19
19
  import { sshDrush } from "./drush.js";
20
20
  import { callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
21
- import { SecurityError } from "../lib/security.js";
21
+ import { SecurityError, configReadBindingScope } from "../lib/security.js";
22
22
  import { validateMachineName } from "../lib/validate.js";
23
23
 
24
24
  const INSPECT = "graphql-compose-codegen:inspect";
@@ -83,7 +83,7 @@ async function runGqcc(
83
83
  ]).get(subcommand);
84
84
  const result = toolResultData(await callBoundModuleTool(site, binding, {
85
85
  bundles: names, skip_fields: skip,
86
- }, { operation: "read", scope: "mcp_config", capabilities: ["configRead"] }));
86
+ }, { operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"] }));
87
87
  if (result?.success !== true || !result.data || typeof result.data !== "object" || Array.isArray(result.data)) {
88
88
  throw new SecurityError("Codegen module returned an invalid result. No SSH fallback was attempted.");
89
89
  }
@@ -24,6 +24,8 @@ import {
24
24
  assertCoreExtensionValueKeepsProtected,
25
25
  isCoreExtensionConfig,
26
26
  hasScope,
27
+ hasConfigReadScope,
28
+ configReadBindingScope,
27
29
  CORE_EXTENSION_CONFIG,
28
30
  SecurityError,
29
31
  } from "../lib/security.js";
@@ -36,8 +38,9 @@ import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../
36
38
  */
37
39
  async function configTool(site, binding, args, operation, capability) {
38
40
  if (site.serverTools?.bindings !== undefined) {
41
+ const scope = operation === "read" ? configReadBindingScope(site) : "mcp_config";
39
42
  return callBoundModuleTool(site, binding, args, {
40
- operation, scope: "mcp_config", capabilities: [capability],
43
+ operation, scope, capabilities: [capability],
41
44
  });
42
45
  }
43
46
  return callGovernedServerTool(site, binding, args);
@@ -144,7 +147,7 @@ async function assertCoreExtensionWriteAllowed(site, sec, value) {
144
147
  async function configSet({ site: siteName, name, value }) {
145
148
  const site = getSiteConfig(siteName);
146
149
  const sec = resolveSecurityConfig(site);
147
- assertConfigScope(site, `config:set ${name}`);
150
+ assertConfigScope(site, `config:set ${name}`, { write: true });
148
151
  assertNotReadOnly(sec, `config:set ${name}`);
149
152
  assertConfigWriteAllowed(sec);
150
153
  if (isCoreExtensionConfig(name)) await assertCoreExtensionWriteAllowed(site, sec, value);
@@ -188,11 +191,13 @@ async function whoami({ site: siteName, _resolvedSource }) {
188
191
  const summary = getSecuritySummary(site);
189
192
  // Effective capability = connector preset AND the scope the server demands.
190
193
  // Reporting the preset alone over-states what the token can do — e.g. the
191
- // content-editor preset allows config reads locally, but every config_* tool
192
- // is gated server-side on mcp_config, which the content tier does not hold.
193
- // When no OAuth scopes are configured, hasScope() is a no-op (preset-only).
194
+ // content-editor preset allows config reads locally, but config_* tools are
195
+ // gated server-side on mcp_config (writes) or mcp_config / mcp_config_read
196
+ // (reads). The content tier holds neither. When no OAuth scopes are
197
+ // configured, hasScope() is a no-op (preset-only).
194
198
  const canWrite = !sec.readOnly && hasScope(site, "mcp_write");
195
199
  const canConfig = hasScope(site, "mcp_config");
200
+ const canConfigRead = hasConfigReadScope(site);
196
201
  const identity = getRequestIdentity();
197
202
  // Dispatch may inject `site` after a default/grant resolution. Prefer the
198
203
  // authoritative source so `target` and `_target` cannot disagree (#167).
@@ -212,7 +217,7 @@ async function whoami({ site: siteName, _resolvedSource }) {
212
217
  read: hasScope(site, "mcp_read"),
213
218
  write: canWrite,
214
219
  delete: sec.allowDestructive && canWrite,
215
- configRead: sec.allowConfigRead && canConfig,
220
+ configRead: sec.allowConfigRead && canConfigRead,
216
221
  configWrite: sec.allowConfigWrite && !sec.readOnly && canConfig,
217
222
  // Local publish policy (allowPublish), symmetric with delete/config caps.
218
223
  // Defaults false in every preset except `development`; the remote Drupal's
@@ -308,7 +308,7 @@ export const definitions = [
308
308
  langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
309
309
  attributes: { type: "object" },
310
310
  relationships: { type: "object" },
311
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
311
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
312
312
  returning: RETURNING_SCHEMA,
313
313
  },
314
314
  },
@@ -41,7 +41,7 @@ import * as config from "./config.js";
41
41
  import * as codegen from "./codegen.js";
42
42
  import { SITE_PARAM } from "../lib/site-target.js";
43
43
 
44
- export const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
44
+ const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
45
45
  revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
46
46
  reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
47
47
 
@@ -15,7 +15,7 @@ import {
15
15
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
16
16
  } from "../lib/security.js";
17
17
  import {
18
- assertDraftLangcode, readDraftTranslation, readTranslationInventory,
18
+ assertDraftLangcode, readDraftTranslation, readTranslationInventory, isUnpublishedWorkingDraft,
19
19
  } from "../lib/sentinel-draft.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
21
  import { entityRevisionId } from "../lib/write-revision.js";
@@ -69,7 +69,7 @@ async function getMedia({ site: siteName, type, id, langcode }) {
69
69
  const targetLang = assertDraftLangcode(langcode);
70
70
  const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
71
71
  const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
72
- if (workingRow && workingRow.status === false && inventory.live?.vid && inventory.working?.vid) {
72
+ if (workingRow && isUnpublishedWorkingDraft(workingRow) && inventory.live?.vid && inventory.working?.vid) {
73
73
  const entity = await readDraftTranslation(backend, {
74
74
  entityType: "media", bundle: type, id, langcode: targetLang,
75
75
  draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
@@ -19,7 +19,9 @@ import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity
19
19
  import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
21
  import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
22
- import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/sentinel-draft.js";
22
+ import {
23
+ assertDraftLangcode, readDraftTranslation, readTranslationInventory, isUnpublishedWorkingDraft,
24
+ } from "../lib/sentinel-draft.js";
23
25
  import { paragraphResourceVersion } from "./paragraphs.js";
24
26
  import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
25
27
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
@@ -294,7 +296,7 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion, in
294
296
  const targetLang = assertDraftLangcode(langcode);
295
297
  const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
296
298
  const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
297
- if (workingRow && workingRow.status === false && inventory.live?.vid && inventory.working?.vid) {
299
+ if (workingRow && isUnpublishedWorkingDraft(workingRow) && inventory.live?.vid && inventory.working?.vid) {
298
300
  const entity = await readDraftTranslation(backend, {
299
301
  entityType: "node", bundle: type, id, langcode: targetLang,
300
302
  draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
@@ -664,7 +666,7 @@ export const definitions = [
664
666
  langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
665
667
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
666
668
  relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
667
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
669
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
668
670
  returning: RETURNING_SCHEMA,
669
671
  },
670
672
  },
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { getSiteConfig } from "../lib/config.js";
21
- import { resolveSecurityConfig, assertConfigReadAllowed } from "../lib/security.js";
21
+ import { resolveSecurityConfig, assertConfigReadAllowed, configReadBindingScope } from "../lib/security.js";
22
22
  import { gatedReport } from "../lib/reports-support.js";
23
23
  import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
24
24
  import { runPrivileged, serverToolsConfigured, drushConfigured } from "../lib/audit-sources.js";
@@ -67,7 +67,7 @@ function configSourceAvailable(site) {
67
67
  async function readConfig(site, name) {
68
68
  if (site.serverTools?.bindings !== undefined) {
69
69
  return toolResultData(await callBoundModuleTool(site, "configGet", { name }, {
70
- operation: "read", scope: "mcp_config", capabilities: ["configRead"],
70
+ operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"],
71
71
  }));
72
72
  }
73
73
  if (serverToolsConfigured(site)) {
@@ -89,7 +89,7 @@ async function readConfig(site, name) {
89
89
  async function listConfigNames(site, prefix) {
90
90
  if (site.serverTools?.bindings !== undefined) {
91
91
  return pickConfigNames(toolResultData(await callBoundModuleTool(site, "configList", { prefix }, {
92
- operation: "read", scope: "mcp_config", capabilities: ["configRead"],
92
+ operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"],
93
93
  })));
94
94
  }
95
95
  if (serverToolsConfigured(site)) {
@@ -308,9 +308,11 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
308
308
  const overdue = [];
309
309
  for (const n of nodes) {
310
310
  for (const [field, action] of [["publish_on", "publish"], ["unpublish_on", "unpublish"]]) {
311
- const raw = scalar(n, [field]);
311
+ // Presence of the key (even null) means Scheduler is exposed (#403).
312
+ const { present, value } = fieldPresence(n, field);
313
+ if (present) sawField = true;
314
+ const raw = value && typeof value === "object" && "value" in value ? value.value : value;
312
315
  if (raw === undefined || raw === null || raw === "" || raw === 0) continue;
313
- sawField = true;
314
316
  const ms = toMillis(raw);
315
317
  if (ms === null) continue;
316
318
  const rec = { id: n.id, title: n.title, action, when: new Date(ms).toISOString(), path: n.url };
@@ -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,8 +6,12 @@
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
  *
@@ -20,6 +24,8 @@
20
24
  import { getSiteConfig } from "../lib/config.js";
21
25
  import { resolveBackend } from "../lib/backends/index.js";
22
26
  import { resolveSecurityConfig, assertWriteAllowed, redactCanonicalEntity } from "../lib/security.js";
27
+ import { entityLooksModerated } from "../lib/moderation-default.js";
28
+ import { fieldPresence } from "../lib/reports-support.js";
23
29
 
24
30
  /**
25
31
  * Heuristic: does this backend error look like a missing/unknown field? Scheduler
@@ -31,7 +37,19 @@ import { resolveSecurityConfig, assertWriteAllowed, redactCanonicalEntity } from
31
37
  */
32
38
  export function looksLikeUnknownField(err) {
33
39
  const msg = String(err?.message || err || "");
34
- return /unknown|not exist|no field|invalid field|unrecognized|publish_on|unpublish_on/i.test(msg);
40
+ return /unknown|not exist|no field|invalid field|unrecognized|publish_on|unpublish_on|publish_state|unpublish_state/i.test(msg);
41
+ }
42
+
43
+ /**
44
+ * Whether a schema or entity exposes a Scheduler attribute.
45
+ * @param {?object} entity Canonical entity.
46
+ * @param {?object} schema Bundle schema from getEntitySchema.
47
+ * @param {string} field Field machine name.
48
+ * @returns {boolean}
49
+ */
50
+ function schedulerFieldPresent(entity, schema, field) {
51
+ if (entity && fieldPresence(entity, field).present) return true;
52
+ return Boolean(schema?.attributes && Object.prototype.hasOwnProperty.call(schema.attributes, field));
35
53
  }
36
54
 
37
55
  /**
@@ -41,12 +59,18 @@ export function looksLikeUnknownField(err) {
41
59
  * canonical updateEntity. At least one of publishOn / unpublishOn must be given.
42
60
  * Each value is forwarded unchanged (ISO 8601 string or epoch integer).
43
61
  *
44
- * @param {object} args - { site?, type, id, publishOn?, unpublishOn? }.
62
+ * On a moderated bundle, `publishState` / `unpublishState` are required when
63
+ * the matching date is set and the Scheduler CM state field exists (#402).
64
+ *
65
+ * @param {object} args - { site?, type, id, publishOn?, unpublishOn?, publishState?, unpublishState? }.
45
66
  * @returns {Promise<object>} The redacted, updated node descriptor.
46
67
  * @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.
68
+ * write, if a moderated schedule is missing its target state, or — degraded —
69
+ * if the Scheduler fields are unknown on the bundle.
48
70
  */
49
- async function schedulePublish({ site: siteName, type, id, publishOn, unpublishOn }) {
71
+ async function schedulePublish({
72
+ site: siteName, type, id, publishOn, unpublishOn, publishState, unpublishState,
73
+ }) {
50
74
  if (publishOn === undefined && unpublishOn === undefined) {
51
75
  throw new Error("Provide at least one of publishOn or unpublishOn to schedule the node.");
52
76
  }
@@ -55,9 +79,65 @@ async function schedulePublish({ site: siteName, type, id, publishOn, unpublishO
55
79
  assertWriteAllowed(sec, "update", "node", type);
56
80
  const backend = await resolveBackend(site);
57
81
 
82
+ let existing = null;
83
+ if (typeof backend.getEntity === "function") {
84
+ try {
85
+ existing = await backend.getEntity({ entityType: "node", bundle: type, id }) ?? null;
86
+ } catch {
87
+ existing = null;
88
+ }
89
+ }
90
+ let schema = null;
91
+ if (existing && typeof backend.getEntitySchema === "function") {
92
+ try {
93
+ schema = await backend.getEntitySchema("node", type);
94
+ } catch {
95
+ schema = null;
96
+ }
97
+ }
98
+
99
+ const moderated = entityLooksModerated(existing);
100
+ if (moderated) {
101
+ if (publishOn !== undefined) {
102
+ const hasStateField = schedulerFieldPresent(existing, schema, "publish_state");
103
+ if (!hasStateField) {
104
+ throw new Error(
105
+ `Could not schedule publish on moderated '${type}': the publish_state field is not available. ` +
106
+ "Moderated bundles need the Scheduler Content Moderation Integration module, and a publishState " +
107
+ "(e.g. 'published') whenever publishOn is set. Without that state, cron logs 'Publishing failed' " +
108
+ "and the node never goes live. See connector #402."
109
+ );
110
+ }
111
+ if (publishState === undefined) {
112
+ throw new Error(
113
+ "Moderated content requires publishState (the workflow state to enter at publishOn, e.g. 'published'). " +
114
+ "Scheduler dates without a target state are accepted by Drupal and then fail at cron. See connector #402."
115
+ );
116
+ }
117
+ }
118
+ if (unpublishOn !== undefined) {
119
+ const hasStateField = schedulerFieldPresent(existing, schema, "unpublish_state");
120
+ if (!hasStateField) {
121
+ throw new Error(
122
+ `Could not schedule unpublish on moderated '${type}': the unpublish_state field is not available. ` +
123
+ "Moderated bundles need the Scheduler Content Moderation Integration module, and an unpublishState " +
124
+ "(e.g. 'draft') whenever unpublishOn is set. See connector #402."
125
+ );
126
+ }
127
+ if (unpublishState === undefined) {
128
+ throw new Error(
129
+ "Moderated content requires unpublishState (the workflow state to enter at unpublishOn, e.g. 'draft'). " +
130
+ "Scheduler dates without a target state are accepted by Drupal and then fail at cron. See connector #402."
131
+ );
132
+ }
133
+ }
134
+ }
135
+
58
136
  const attributes = {};
59
137
  if (publishOn !== undefined) attributes.publish_on = publishOn;
60
138
  if (unpublishOn !== undefined) attributes.unpublish_on = unpublishOn;
139
+ if (publishState !== undefined) attributes.publish_state = publishState;
140
+ if (unpublishState !== undefined) attributes.unpublish_state = unpublishState;
61
141
 
62
142
  let updated;
63
143
  try {
@@ -67,6 +147,7 @@ async function schedulePublish({ site: siteName, type, id, publishOn, unpublishO
67
147
  throw new Error(
68
148
  `Could not set Scheduler dates on '${type}': the publish_on / unpublish_on fields are not available. ` +
69
149
  "This tool requires the Drupal Scheduler module to be installed and enabled for this content type. " +
150
+ "Moderated bundles also need Scheduler Content Moderation Integration (publish_state / unpublish_state). " +
70
151
  `Backend error: ${err?.message || err}`,
71
152
  );
72
153
  }
@@ -85,18 +166,24 @@ export const definitions = [
85
166
  description:
86
167
  "Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. " +
87
168
  "Sets the publish_on and unpublish_on fields on the node. " +
169
+ "On a bundle under Content Moderation, also set publishState / unpublishState " +
170
+ "(written as publish_state / unpublish_state) — dates alone never go live. " +
88
171
  "Requires the Scheduler module to be installed and enabled for the content type, with the publish_on / " +
89
172
  "unpublish_on fields present on the bundle — otherwise the call fails with a clear capability error. " +
173
+ "Moderated bundles additionally need Scheduler Content Moderation Integration; when those state fields " +
174
+ "exist, the matching state is required whenever a date is set. " +
90
175
  "Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged. " +
91
176
  "Provide at least one of publishOn or unpublishOn.",
92
177
  inputSchema: {
93
178
  type: "object", required: ["type", "id"],
94
179
  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." },
180
+ site: { type: "string", description: "Named site (omit for default)" },
181
+ type: { type: "string", description: "Content type machine name, e.g. 'article'" },
182
+ id: { type: "string", description: "Node UUID" },
183
+ publishOn: { type: ["string", "number"], description: "When to publish — ISO 8601 datetime or Unix epoch. Sets the Scheduler publish_on field." },
184
+ unpublishOn: { type: ["string", "number"], description: "When to unpublish — ISO 8601 datetime or Unix epoch. Sets the Scheduler unpublish_on field." },
185
+ publishState: { type: "string", description: "Moderation state to enter at publishOn (e.g. 'published'). Required on moderated bundles when publishOn is set." },
186
+ unpublishState: { type: "string", description: "Moderation state to enter at unpublishOn (e.g. 'draft'). Required on moderated bundles when unpublishOn is set." },
100
187
  },
101
188
  },
102
189
  },
@@ -156,7 +156,7 @@ async function createMenuLink({ site: siteName, title, link, menu, weight, paren
156
156
  * site — so an unrelated edit (rename, re-weight, re-parent) can never silently
157
157
  * regress a live link to disabled. Set `parent` to re-nest a link.
158
158
  *
159
- * @param {object} args - { site?, id, title?, link?, menu?, weight?, parent?, enabled? }.
159
+ * @param {object} args - { site?, id, title?, link?, menu?, weight?, parent?, enabled?, langcode? }.
160
160
  * @returns {Promise<object>} The updated menu-link descriptor from the backend.
161
161
  * @throws {Error} If id is missing.
162
162
  * @throws {SecurityError} If updating menu_link_content is not permitted.
@@ -243,7 +243,7 @@ async function createBlock({ site: siteName, type, info, body }) {
243
243
  export const definitions = [
244
244
  {
245
245
  name: "drupal_list_menu_links",
246
- description: "List custom (content) menu links, optionally scoped to a single menu (e.g. 'main', 'footer'). Returns each link's title, target URI, menu, and weight. Note: this lists editable menu_link_content entities, not code-defined static links.",
246
+ description: "List custom (content) menu links, optionally scoped to a single menu (e.g. 'main', 'footer'). Returns each link's title, target URI, menu, weight, and fields.drupal_internal__id (numeric id for /admin/structure/menu/item/{id}/edit). Note: this lists editable menu_link_content entities, not code-defined static links.",
247
247
  inputSchema: {
248
248
  type: "object",
249
249
  properties: {
@@ -273,7 +273,7 @@ export const definitions = [
273
273
  },
274
274
  {
275
275
  name: "drupal_update_menu_link",
276
- description: "Update a custom menu link by UUID (rename, re-weight, re-target, re-parent, enable/disable). Only the fields you pass change. The link's enabled state is preserved across edits — an unrelated change will not disable a live link — unless you pass 'enabled' explicitly. Checked against the site security config.",
276
+ description: "Update a custom menu link by UUID (rename, re-weight, re-target, re-parent, enable/disable). Only the fields you pass change. The link's enabled state is preserved across edits — an unrelated change will not disable a live link — unless you pass 'enabled' explicitly. Pass langcode to update an existing translation via JSON:API language negotiation (URL prefix, then language headers). Does not create a missing translation. Responses include fields.drupal_internal__id. Checked against the site security config.",
277
277
  inputSchema: {
278
278
  type: "object", required: ["id"],
279
279
  properties: {
@@ -285,7 +285,7 @@ export const definitions = [
285
285
  weight: { type: "number", description: "New ordering weight. Omit to leave unchanged." },
286
286
  parent: { type: "string", description: "New parent link plugin id (e.g. 'menu_link_content:<uuid>'), or '' for top level. Omit to leave unchanged." },
287
287
  enabled: { type: "boolean", description: "Enable/disable the link. Omit to preserve the current state." },
288
- langcode: { type: "string", description: "Existing translation to update (e.g. 'es'). Omit for the default language. Does not create a missing translation." },
288
+ langcode: { type: "string", description: "Existing translation to update (e.g. 'es'). Uses /{langcode}/jsonapi/… when the site prefixes JSON:API, then language headers. Omit for the default language. Does not create a missing translation." },
289
289
  },
290
290
  },
291
291
  },
@@ -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)) {