drupal-mcp-connector 3.0.1 → 3.2.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.
@@ -18,7 +18,7 @@ const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload_",
18
18
  "drupal_drush_updatedb", "drupal_drush_module_enable",
19
19
  "drupal_drush_module_disable", "drupal_drush_user_create",
20
20
  // v1.0 feature tools that perform writes but don't start with create_/update_:
21
- "drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_",
21
+ "drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_", "drupal_draft_",
22
22
  // Governed config write (also gated inside the handler by the config-write cap):
23
23
  "drupal_config_set"];
24
24
 
@@ -24,6 +24,12 @@
24
24
 
25
25
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
26
26
  import { entityRevisionId } from "./write-revision.js";
27
+ import {
28
+ isCanonicalDraftType,
29
+ preflightGovernedCanonicalDraft,
30
+ resolveCanonicalDraftIdentity,
31
+ writeCanonicalModeratedEntity,
32
+ } from "./canonical-draft.js";
27
33
  import { httpStatusOf } from "./error-status.js";
28
34
  import {
29
35
  writeDraft,
@@ -245,6 +251,20 @@ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
245
251
  * @throws {PatchTargetAmbiguousError}
246
252
  */
247
253
  export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
254
+ if (isCanonicalDraftType(entityType)) {
255
+ // A readable unmoderated block or library item has no draft alias to
256
+ // prove. Identity resolution stays fail-closed when the entity is
257
+ // moderated or could not be read.
258
+ if (existing && !entityLooksModerated(existing)) {
259
+ return {
260
+ resourceVersion: undefined,
261
+ workingCopy: null,
262
+ liveVid: entityRevisionId(existing, entityType),
263
+ workingVid: null,
264
+ };
265
+ }
266
+ return resolveCanonicalDraftIdentity(backend, { entityType, bundle, id, existing });
267
+ }
248
268
  const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
249
269
  if (workingCopy?.id && workingCopy.id !== id) {
250
270
  throw new PatchTargetAmbiguousError(id, workingCopy.id);
@@ -478,6 +498,17 @@ export async function prepareGuardedPatch(backend, {
478
498
  target.preflight = PREFLIGHT_SENTINEL_DRAFT;
479
499
  return target;
480
500
  }
501
+ if (target.governedDraft) {
502
+ try {
503
+ await preflightGovernedCanonicalDraft(backend, {
504
+ entityType, bundle, id, attributes, relationships, governedDraft: target.governedDraft,
505
+ });
506
+ } catch (err) {
507
+ throw rewriteStaleCopyError(err);
508
+ }
509
+ target.preflight = PREFLIGHT_SENTINEL_DRAFT;
510
+ return target;
511
+ }
481
512
  if (target.resourceVersion) {
482
513
  const draftRevisionFor = (lang) => ({
483
514
  liveVid: target.liveVid,
@@ -584,6 +615,9 @@ function explainDefaultLanguageRefusal(err, inferredLangcode) {
584
615
  * @throws {PatchBlockedError|WorkingCopyStaleError|*}
585
616
  */
586
617
  export async function updateEntityGuarded(backend, input) {
618
+ if (isCanonicalDraftType(input?.entityType) && !input?.draftRevision && !input?.resourceVersion) {
619
+ return writeCanonicalModeratedEntity(backend, input);
620
+ }
587
621
  try {
588
622
  if (input?.draftRevision) return await writeDraft(backend, input);
589
623
  if (input?.resourceVersion) {
@@ -11,27 +11,73 @@
11
11
  */
12
12
 
13
13
  /**
14
- * Read a revision id off a canonical (or raw-ish) entity body.
15
- * @param {?object} entity
14
+ * JSON:API revision attribute for entity types whose revision key is known.
15
+ * `taxonomy_term`'s `drupal_internal__vid` is the vocabulary id, not a revision.
16
+ * Node and media revisions use `vid`. Paragraphs, library items, and custom
17
+ * blocks use `revision_id`.
18
+ */
19
+ const REVISION_ATTRIBUTE = {
20
+ node: "drupal_internal__vid",
21
+ media: "drupal_internal__vid",
22
+ paragraph: "drupal_internal__revision_id",
23
+ paragraphs_library_item: "drupal_internal__revision_id",
24
+ block_content: "drupal_internal__revision_id",
25
+ };
26
+
27
+ /**
28
+ * @param {object} entity
29
+ * @param {object} fields
30
+ * @param {object} attrs
31
+ * @param {string} key
32
+ * @returns {unknown}
33
+ */
34
+ function readRevisionAttribute(entity, fields, attrs, key) {
35
+ if (key === "drupal_internal__vid") {
36
+ return entity.vid
37
+ ?? fields.drupal_internal__vid
38
+ ?? attrs.drupal_internal__vid
39
+ ?? entity.drupal_internal__vid;
40
+ }
41
+ if (Object.prototype.hasOwnProperty.call(fields, key)) return fields[key];
42
+ if (Object.prototype.hasOwnProperty.call(attrs, key)) return attrs[key];
43
+ if (Object.prototype.hasOwnProperty.call(entity, key)) return entity[key];
44
+ return undefined;
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} raw
16
49
  * @returns {?number|string}
17
50
  */
18
- export function entityRevisionId(entity) {
19
- if (!entity || typeof entity !== "object") return null;
20
- const fields = entity.fields && typeof entity.fields === "object"
21
- ? entity.fields
22
- : {};
23
- const attrs = entity.attributes && typeof entity.attributes === "object"
24
- ? entity.attributes
25
- : {};
26
- const raw = entity.vid
27
- ?? fields.drupal_internal__vid
28
- ?? attrs.drupal_internal__vid
29
- ?? entity.drupal_internal__vid;
51
+ function normalizeRevisionId(raw) {
30
52
  if (raw === undefined || raw === null || raw === "") return null;
31
53
  const n = Number(raw);
32
54
  return Number.isFinite(n) ? n : raw;
33
55
  }
34
56
 
57
+ /**
58
+ * Read a revision id off a canonical (or raw-ish) entity body.
59
+ * When `entityType` is known, only that type's revision attribute is read.
60
+ * When it is omitted, a node-style vid is kept and `revision_id` is used
61
+ * only if no vid is present, so existing callers stay unchanged.
62
+ *
63
+ * @param {?object} entity
64
+ * @param {?string} [entityType]
65
+ * @returns {?number|string}
66
+ */
67
+ export function entityRevisionId(entity, entityType) {
68
+ if (!entity || typeof entity !== "object") return null;
69
+ const type = entityType || entity.entityType || null;
70
+ const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : {};
71
+ const attrs = entity.attributes && typeof entity.attributes === "object" ? entity.attributes : {};
72
+ if (type === "taxonomy_term") return null;
73
+ if (type && REVISION_ATTRIBUTE[type]) {
74
+ return normalizeRevisionId(readRevisionAttribute(entity, fields, attrs, REVISION_ATTRIBUTE[type]));
75
+ }
76
+ const vid = readRevisionAttribute(entity, fields, attrs, "drupal_internal__vid");
77
+ if (vid !== undefined && vid !== null && vid !== "") return normalizeRevisionId(vid);
78
+ return normalizeRevisionId(readRevisionAttribute(entity, fields, attrs, "drupal_internal__revision_id"));
79
+ }
80
+
35
81
  /**
36
82
  * Attach distinct live vs working revision ids when both are known.
37
83
  * Does not invent vids.
@@ -65,7 +111,8 @@ export async function attachWrittenRevisionPair({
65
111
  backend, entityType, bundle, id, entity, liveVid,
66
112
  }) {
67
113
  if (liveVid === null || liveVid === undefined || !entity) return entity;
68
- let workingVid = entityRevisionId(entity);
114
+ if (entity._revisions) return entity;
115
+ let workingVid = entityRevisionId(entity, entityType);
69
116
  if (workingVid !== null && String(workingVid) !== String(liveVid)) {
70
117
  return attachRevisionPair(entity, { live: liveVid, working: workingVid });
71
118
  }
@@ -74,7 +121,7 @@ export async function attachWrittenRevisionPair({
74
121
  const wc = await backend.getEntity({
75
122
  entityType, bundle, id, resourceVersion: "rel:working-copy",
76
123
  });
77
- workingVid = entityRevisionId(wc);
124
+ workingVid = entityRevisionId(wc, entityType);
78
125
  } catch {
79
126
  workingVid = null;
80
127
  }
@@ -106,6 +153,9 @@ export async function readWrittenRevision({
106
153
  // Sentinel returned the requested translation; a canonical read would replace
107
154
  // it with live English, and the core working-copy alias may not address it.
108
155
  if (langcode && patchResult) return patchResult;
156
+ if (patchResult?._revision?.source === "working-copy" || patchResult?._revision?.source === "latest-version") {
157
+ return patchResult;
158
+ }
109
159
  if (!relationshipsSent) {
110
160
  if (preferCanonical && typeof backend.getEntity === "function") {
111
161
  const fresh = await backend.getEntity({
package/src/tools/bulk.js CHANGED
@@ -14,7 +14,8 @@
14
14
  import { getSiteConfig } from "../lib/config.js";
15
15
  import { resolveBackend } from "../lib/backends/index.js";
16
16
  import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed, withUnpublishedDefault } from "../lib/security.js";
17
- import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
17
+ import { applySafeDraftDefault } from "../lib/moderation-default.js";
18
+ import { readUpdateTarget } from "../lib/canonical-draft.js";
18
19
  import {
19
20
  resolveErrRelationships, embedParagraphRef,
20
21
  resolveParagraphRevisionId, missingParagraphRevisionError,
@@ -103,14 +104,9 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
103
104
  const item = rawItem || {};
104
105
  try {
105
106
  if (!item.id) throw new Error("Missing 'id' for update item");
106
- let existing = null;
107
- if (!hasExplicitModerationState(item.attributes ?? {})) {
108
- try {
109
- existing = (await backend.getEntity({ entityType, bundle, id: item.id })) ?? null;
110
- } catch {
111
- existing = null;
112
- }
113
- }
107
+ const existing = await readUpdateTarget(backend, {
108
+ entityType, bundle, id: item.id, attributes: item.attributes ?? {},
109
+ });
114
110
  const attributes = await applySafeDraftDefault({
115
111
  backend, entityType, bundle, id: item.id,
116
112
  attributes: item.attributes ?? {},
@@ -11,13 +11,14 @@
11
11
  import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
13
  import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
14
- import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
14
+ import { applySafeDraftDefault } from "../lib/moderation-default.js";
15
15
  import {
16
16
  resolveErrRelationships, relationshipsWereSent, embedParagraphRef,
17
17
  resolveParagraphRevisionId, missingParagraphRevisionError,
18
18
  } from "../lib/err-relationships.js";
19
19
  import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
+ import { readUpdateTarget } from "../lib/canonical-draft.js";
21
22
  import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
22
23
  import {
23
24
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
@@ -111,14 +112,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
111
112
  // One pre-write read serves both the #131 draft default and the #171
112
113
  // unrequested-status-change flag. Skipped when the caller pinned the
113
114
  // moderation state explicitly (same condition the draft default uses).
114
- let existing = null;
115
- if (!hasExplicitModerationState(attributes)) {
116
- try {
117
- existing = (await backend.getEntity({ entityType, bundle, id })) ?? null;
118
- } catch {
119
- existing = null; // Unreadable target: server-side gates stay authoritative.
120
- }
121
- }
115
+ const existing = await readUpdateTarget(backend, { entityType, bundle, id, attributes });
122
116
  const safeAttributes = await applySafeDraftDefault({
123
117
  backend, entityType, bundle, id, attributes, existingEntity: existing,
124
118
  });
@@ -297,7 +291,7 @@ export const definitions = [
297
291
  },
298
292
  {
299
293
  name: "drupal_entity_update",
300
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.",
294
+ description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran. When the draft inventory advertises open_draft, a published moderated paragraphs_library_item or block_content opens and continues through /mcp-draft with If-Match live or live:working, no mcp_components, and a re-read of the published pins. A continuation compares the forward revision with the pre-write working pins. When open_draft is not advertised, a published moderated paragraphs_library_item or block_content with no forward revision is saved by that canonical draft PATCH, then the published revision, rel:working-copy, and paragraph pins (including order) are re-read. The result fails if the published revision changed or no forward revision appeared. An existing forward draft, a missing revision id, or a denied version read is refused before any write. An unmoderated item keeps the ordinary update. A completed 4xx is returned when that re-read matches the pre-write revision and pins; a lost response stays uncertain (#420).",
301
295
  inputSchema: {
302
296
  type: "object", required: ["entityType", "bundle", "id"],
303
297
  properties: {
@@ -308,7 +302,7 @@ export const definitions = [
308
302
  langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
309
303
  attributes: { type: "object" },
310
304
  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 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." },
305
+ 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. An existing node draft (or a langcode translation draft), and a paragraphs_library_item or block_content whose inventory advertises open_draft, are 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
306
  returning: RETURNING_SCHEMA,
313
307
  },
314
308
  },
@@ -29,6 +29,7 @@ import * as references from "./references.js";
29
29
  import * as bulk from "./bulk.js";
30
30
  import * as translations from "./translations.js";
31
31
  import * as paragraphs from "./paragraphs.js";
32
+ import * as nested from "./nested.js";
32
33
  import * as structure from "./structure.js";
33
34
  import * as redirects from "./redirects.js";
34
35
  import * as search from "./search.js";
@@ -42,7 +43,7 @@ import * as codegen from "./codegen.js";
42
43
  import { SITE_PARAM } from "../lib/site-target.js";
43
44
 
44
45
  const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
45
- revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
46
+ revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, nested, structure, redirects, search, reportsExtra,
46
47
  reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
47
48
 
48
49
  /**
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tool: governed nested-paragraph draft (#421).
3
+ *
4
+ * Direct component attributes stay on drupal_update_node `components`.
5
+ * A nested child is a new unpublished paragraph, pinned only by the host
6
+ * draft. This tool never calls updateEntity on a paragraph.
7
+ */
8
+
9
+ import { getSiteConfig } from "../lib/config.js";
10
+ import { resolveBackend } from "../lib/backends/index.js";
11
+ import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
12
+ import { draftNestedComponents } from "../lib/nested-draft.js";
13
+
14
+ /**
15
+ * @param {object} args
16
+ * @returns {Promise<object>}
17
+ */
18
+ async function draftNestedComponentsTool({
19
+ site: siteName, bundle, id, field, parentId, childField, children,
20
+ liveRevisionId, resumeParentId, dryRun = false,
21
+ }) {
22
+ const site = getSiteConfig(siteName);
23
+ const sec = resolveSecurityConfig(site);
24
+ assertWriteAllowed(sec, "update", "node", bundle);
25
+ assertPublishAllowed(sec, { moderation_state: "draft" });
26
+ const backend = await resolveBackend(site);
27
+ return draftNestedComponents(backend, {
28
+ entityType: "node", bundle, id, field, parentId, childField, children,
29
+ liveRevisionId, resumeParentId,
30
+ }, {
31
+ dryRun,
32
+ assertParagraphCreate(paragraphBundle) {
33
+ assertWriteAllowed(sec, "create", "paragraph", paragraphBundle);
34
+ },
35
+ });
36
+ }
37
+
38
+ export const definitions = [
39
+ {
40
+ name: "drupal_draft_nested_components",
41
+ description:
42
+ "Replace nested paragraphs on a node by creating new unpublished paragraphs and pinning a new direct parent from the node's unpublished draft. children is the full new list: { op: 'keep', id } copies an existing child pin, { op: 'replace', id, type, attributes, translations? } or { op: 'insert', type, attributes, translations? } creates a new child. Omitting a child leaves it off the draft only; it is not deleted. The published host and every paragraph it already pins stay unchanged. Never edits a live child and never sends nested UUIDs as mcp_components (use drupal_update_node components for a direct paragraph's attributes). Reusable library items and from_library children are refused; update the library item with drupal_entity_update. The first host draft requires MCP Sentinel 2.29.0 (open_draft). An existing working copy continues through /mcp-draft. When the host inventory advertises nested_replacement, one /mcp-draft carries meta.mcp_nested_replacement (parent is the parentId input), the connector creates no paragraphs, and it does not also send the host paragraph field. dryRun then uses that non-saving preflight (serverPreflight sentinel_draft). Hosts that do not advertise nested_replacement keep this sequence, including resumeParentId, and dryRun leaves serverPreflight none. New paragraphs are created in the host's default language. The new parent keeps the published parent's other references and replaces only the child field. Non-default translations on the component must be supplied on each replace or insert; they are created on the new paragraphs, and the component's own translations are copied onto the new parent. dryRun checks paragraph-create permission, creates nothing, and does not evaluate the host payload. If paragraph creates succeed and the host pin fails, the error lists the prepared UUIDs. When a parent was prepared, retry with resumeParentId and the same children list; that retry checks the prepared paragraph's fields, other references, and child revision pins before any host write. A failure before a parent exists says to delete the prepared paragraphs and does not offer a host retry. A lost response is uncertain until a re-read proves the prepared revision is pinned; the same UUID at another revision is not success. A second call after success is refused while the forward draft no longer pins the published component. Governed by the site security policy. Nothing is published.",
43
+ inputSchema: {
44
+ type: "object",
45
+ required: ["bundle", "id", "field", "parentId", "childField", "children"],
46
+ properties: {
47
+ site: { type: "string", description: "Named site (omit for default)" },
48
+ bundle: { type: "string", description: "Node bundle machine name, e.g. 'page'" },
49
+ id: { type: "string", description: "Node UUID" },
50
+ field: { type: "string", description: "Host entity-reference-revisions field that pins the direct parent, e.g. 'field_components'" },
51
+ parentId: { type: "string", description: "UUID of the direct paragraph the published host pins. Not a nested child." },
52
+ childField: { type: "string", description: "Entity-reference-revisions field on that direct paragraph, e.g. 'field_items'" },
53
+ children: {
54
+ type: "array",
55
+ description: "Full new child list, in order. keep: { op, id }. replace: { op, id, type, attributes, translations? }. insert: { op, type, attributes, translations? }. translations is [{ langcode, attributes }] for each non-default language the component already has. Relationships inside a child are refused.",
56
+ items: { type: "object" },
57
+ },
58
+ liveRevisionId: { type: "string", description: "Optional published revision id. Refused before any create when it is not the current live revision." },
59
+ resumeParentId: { type: "string", description: "Prepared parent UUID from a previous attempt whose host pin did not finish. Retries only the host pin. Must not be a paragraph the published host already pins." },
60
+ dryRun: { type: "boolean", default: false, description: "When nested_replacement is advertised, run that non-saving /mcp-draft preflight and create nothing (checks.serverPreflight is sentinel_draft). Otherwise read the host and component, then return without creating paragraphs or evaluating the host draft payload (checks.serverPreflight is none)." },
61
+ },
62
+ },
63
+ },
64
+ ];
65
+
66
+ export const handlers = {
67
+ drupal_draft_nested_components: draftNestedComponentsTool,
68
+ };
@@ -28,6 +28,7 @@ import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body
28
28
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
29
29
  import { applyAllowedFormatsToAttributes, attributesOmitTextFormat } from "../lib/field-definition.js";
30
30
  import { normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../lib/path-alias.js";
31
+ import { writeComponentDraft, normalizeComponents } from "../lib/component-draft.js";
31
32
 
32
33
  /** Fallback language for an alias when the node exposes none. */
33
34
  const DEFAULT_ALIAS_LANGCODE = "en";
@@ -464,10 +465,16 @@ async function createNode({ site: siteName, type, title, body, summary, format,
464
465
  * @param {object} args - { site?, type, id, title?, body?, summary?, format?, status?, moderationState?, fields?, relationships? }.
465
466
  * @returns {Promise<object>} The updated node descriptor.
466
467
  */
467
- async function updateNode({ site: siteName, type, id, title, body, summary, format, status, moderationState, langcode, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
468
+ async function updateNode({ site: siteName, type, id, title, body, summary, format, status, moderationState, langcode, fields = {}, relationships = {}, components, dryRun = false, returning = "full" }) {
468
469
  const site = getSiteConfig(siteName);
469
470
  const sec = resolveSecurityConfig(site);
470
471
  assertWriteAllowed(sec, "update", "node", type);
472
+ // #416: a component change is a paragraph write; the paragraph policy
473
+ // applies to each one, as it does on drupal_update_paragraph.
474
+ const normalizedComponents = components === undefined ? undefined : normalizeComponents(components);
475
+ for (const component of normalizedComponents ?? []) {
476
+ assertWriteAllowed(sec, "update", "paragraph", component.type.slice("paragraph--".length));
477
+ }
471
478
  const backend = await resolveBackend(site);
472
479
  const summaryWrite = await assertBodySummaryWritable(backend, type, summary);
473
480
  let attributes = { ...fields };
@@ -503,6 +510,16 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
503
510
  // #192: resolve paragraph ERR identifiers before any host PATCH. An unresolved
504
511
  // list would persist empty — fail the whole write instead.
505
512
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
513
+ // #416: component paragraph changes ride the same draft save as the node
514
+ // fields through Sentinel. There is no core PATCH or alias step on this path.
515
+ if (normalizedComponents !== undefined) {
516
+ const written = await writeComponentDraft(backend, {
517
+ entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
518
+ components: normalizedComponents, langcode,
519
+ }, { dryRun });
520
+ const shaped = dryRun ? written : shapeWriteResponse(written, returning);
521
+ return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(shaped) : shaped;
522
+ }
506
523
  // #201 / #166: probe the write endpoint. Addressable drafts use Sentinel's
507
524
  // governed continuation endpoint; stray revisions still fail the probe.
508
525
  const patchTarget = await prepareGuardedPatch(backend, {
@@ -672,6 +689,19 @@ export const definitions = [
672
689
  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." },
673
690
  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." },
674
691
  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." },
692
+ components: {
693
+ type: "array",
694
+ description: "Field changes for paragraphs this node already references directly (hero, text blocks, CTA banners), saved in the same unpublished draft revision as the node fields. Each entry is { id: paragraph UUID, type: paragraph bundle such as 'p_hero', attributes: { field: value } }. Each changed paragraph gets a new revision that only the draft points at; the live page does not change until the draft is published. Opens the working copy from live when none exists. Default language only. Attribute values only: references (for example a media field) are refused, as are paragraphs the node does not reference. A nested paragraph is refused before any write; use drupal_draft_nested_components. Requires MCP Sentinel 2.29.0 or later; older hosts are refused before any write, with no direct paragraph write. A `path` change is refused on this path. The connector's paragraph write policy applies to each component. After the write each component is read back at its working revision and the submitted values compared; a value that did not land is an error. The result carries `_components` (each component's live and working pins and the fields verified), which survives `returning: 'minimal'`.",
695
+ items: {
696
+ type: "object",
697
+ required: ["id", "type", "attributes"],
698
+ properties: {
699
+ id: { type: "string", description: "Paragraph UUID" },
700
+ type: { type: "string", description: "Paragraph bundle, e.g. 'p_hero' (or 'paragraph--p_hero')" },
701
+ attributes: { type: "object", description: "Paragraph field values keyed by machine name" },
702
+ },
703
+ },
704
+ },
675
705
  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." },
676
706
  returning: RETURNING_SCHEMA,
677
707
  },