drupal-mcp-connector 3.0.1 → 3.1.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is still published (on live, or on the working copy) is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here 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."
3
- argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [langcode] [fields] [relationships] [dryRun] [returning]"
3
+ argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [langcode] [fields] [relationships] [components] [dryRun] [returning]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_update_node`.
@@ -24,6 +24,7 @@ Parse the arguments supplied with this command into this tool's parameters:
24
24
  - `langcode` (string): 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.
25
25
  - `fields` (object (pass as JSON)): 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.
26
26
  - `relationships` (object (pass as JSON)): 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.
27
+ - `components` (array (pass as JSON)): 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 nested paragraphs and paragraphs the node does not reference. 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'`.
27
28
  - `dryRun` (boolean (true/false)): 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.
28
29
  - `returning` (one of: full, minimal): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
29
30
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.0] - 2026-10-03
11
+
12
+ ### Added
13
+ - **`drupal_update_node` `components` (#416).** Field changes for paragraphs
14
+ the node references (hero, text blocks, CTA banners) are saved in the same
15
+ unpublished draft revision as the node fields. The live page does not
16
+ change until the draft is published. Opens the working copy from live when
17
+ none exists. Requires MCP Sentinel 2.29.0 (`draft_components`,
18
+ `open_draft`); older hosts are refused before any write, and there is no
19
+ fallback to a direct paragraph write. The paragraph write policy applies to
20
+ each component. After the write, each component is read back at its
21
+ working revision and the submitted values are compared; a value that did
22
+ not land is reported as an error, not a success.
23
+ - **stdio Cursor plugin scaffold.** `.cursor-plugin/plugin.json`,
24
+ root `mcp.json` (`npx -y drupal-mcp-connector`, no `url`), one short
25
+ skill, and [PRIVACY.md](PRIVACY.md). No hosted MCP URL and no listing
26
+ submit.
27
+
28
+ ### Changed
29
+ - **SECURITY.md supported versions** now list 3.x (not 0.x).
30
+ - **CI and release run the advertised quality gate.** `npm run check` is
31
+ lint + high-severity `npm audit` + `syntax-check` + test. CI and the
32
+ release workflow both run that command, so a high advisory fails CI and
33
+ a tag that fails syntax-check does not publish. Lockfile pins
34
+ `brace-expansion` `5.0.12` so the new audit step is clean.
35
+
10
36
  ## [3.0.1] - 2026-10-01
11
37
 
12
38
  ### Fixed
package/README.md CHANGED
@@ -239,6 +239,20 @@ Drupal. See **[docs/mcp-clients.md](docs/mcp-clients.md)**.
239
239
  entry — this repo does not submit listings. If the client only shows
240
240
  `drupal_list_sites` and `drupal_governance_status`, run `doctor`.
241
241
 
242
+ ### Cursor plugin (stdio)
243
+
244
+ Local skeleton only (`.cursor-plugin/plugin.json` + root `mcp.json`): the client
245
+ launches `npx -y drupal-mcp-connector` on this machine. No hosted URL.
246
+
247
+ ```bash
248
+ npx -y drupal-mcp-connector init --preset local-stdio
249
+ npx -y drupal-mcp-connector doctor
250
+ ```
251
+
252
+ `requireGovernance: true` is the listing default. `--preset public-https` stays
253
+ gated. Marketplace listing submit is **operator-only** — this repo does not submit.
254
+ See [PRIVACY.md](PRIVACY.md).
255
+
242
256
  ### Two-minute happy path
243
257
 
244
258
  After `init` and a green `doctor`, in the MCP client (real tool names):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Multi-site MCP server for Drupal over JSON:API and GraphQL, with governed writes and an SSH Drush bridge. init writes a stdio or Tailscale client config from a transport preset. doctor checks reachability, auth, and readiness, and reports version skew. A timestamp gap on a published revision no longer blocks the next draft.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -62,7 +62,7 @@
62
62
  "test": "vitest run",
63
63
  "test:watch": "vitest",
64
64
  "audit": "npm audit --audit-level=high",
65
- "check": "npm run lint && npm run audit",
65
+ "check": "npm run lint && npm run audit && npm run syntax-check && npm test",
66
66
  "syntax-check": "find src -name '*.js' -exec sh -c 'node --input-type=module --check < \"$1\" && echo \"$1 ✓\"' _ {} \\;",
67
67
  "verify": "node bin/drupal-mcp-verify.js",
68
68
  "verify:example": "node bin/drupal-mcp-verify.js --config config/config.example.json",
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Component paragraph changes carried in one governed node draft
3
+ * (drupal-mcp-connector #416, MCP Sentinel d.o #3627742).
4
+ *
5
+ * The node edit form saves a draft once: each changed paragraph becomes a
6
+ * new non-default revision and the draft points at it, while the live
7
+ * revision keeps its pins. Sentinel's `/mcp-draft` does the same when the
8
+ * request carries `meta.mcp_components`. This module validates the
9
+ * components, checks that the host advertises the operations, preflights the
10
+ * exact payload, writes it, and verifies the working copy moved each pin.
11
+ *
12
+ * There is no fallback. A host without the operations is refused before any
13
+ * write; a direct paragraph PATCH would change the live page.
14
+ */
15
+
16
+ import { readNodeDraftInventory, assertDraftLangcode } from "./sentinel-draft.js";
17
+ import { entityRevisionId } from "./write-revision.js";
18
+ import { paragraphPinsFromEntity } from "./err-relationships.js";
19
+ import { PREFLIGHT_SENTINEL_DRAFT, dryRunChecks } from "./dry-run-checks.js";
20
+
21
+ /** Inventory operation: component paragraph changes inside a draft save. */
22
+ export const DRAFT_COMPONENTS_OPERATION = "draft_components";
23
+ /** Inventory operation: open the first working copy from live (`If-Match: "live"`). */
24
+ export const OPEN_DRAFT_OPERATION = "open_draft";
25
+ /** First MCP Sentinel release with both operations. */
26
+ export const MIN_SENTINEL_COMPONENTS_VERSION = "2.29.0";
27
+
28
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
+ const BUNDLE_RE = /^[a-z][a-z0-9_]*$/;
30
+
31
+ /**
32
+ * Validate component entries and normalize `type` to `paragraph--<bundle>`.
33
+ * @param {unknown} components
34
+ * @returns {Array<{type: string, id: string, attributes: object}>}
35
+ */
36
+ export function normalizeComponents(components) {
37
+ if (!Array.isArray(components) || components.length === 0) {
38
+ throw new Error("components must be a non-empty list of { id, type, attributes }.");
39
+ }
40
+ const seen = new Set();
41
+ return components.map((component, index) => {
42
+ const where = `components[${index}]`;
43
+ if (!component || typeof component !== "object" || Array.isArray(component)) {
44
+ throw new Error(`${where} must be an object with id, type and attributes.`);
45
+ }
46
+ if (Object.hasOwn(component, "relationships")) {
47
+ throw new Error(`${where}: a component change cannot change references. Send attribute values only.`);
48
+ }
49
+ const id = String(component.id ?? "");
50
+ if (!UUID_RE.test(id)) throw new Error(`${where}.id must be the paragraph UUID.`);
51
+ // UUID text is case-insensitive; dedupe on one canonical form.
52
+ const key = id.toLowerCase();
53
+ if (seen.has(key)) throw new Error(`${where}: paragraph ${id} is listed more than once.`);
54
+ seen.add(key);
55
+ const rawType = String(component.type ?? "");
56
+ const bundle = rawType.startsWith("paragraph--") ? rawType.slice("paragraph--".length) : rawType;
57
+ if (rawType.includes("--") && !rawType.startsWith("paragraph--")) {
58
+ throw new Error(`${where}.type must be a paragraph bundle, not ${rawType}.`);
59
+ }
60
+ if (!BUNDLE_RE.test(bundle)) throw new Error(`${where}.type must be a paragraph bundle such as "p_hero".`);
61
+ const attributes = component.attributes;
62
+ if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)
63
+ || Object.keys(attributes).length === 0) {
64
+ throw new Error(`${where}.attributes must name at least one field.`);
65
+ }
66
+ return { type: `paragraph--${bundle}`, id, attributes };
67
+ });
68
+ }
69
+
70
+ /**
71
+ * @param {string} operation
72
+ * @returns {Error}
73
+ */
74
+ function capabilityError(operation) {
75
+ return new Error(
76
+ `This Sentinel host cannot save component changes in a draft (missing ${operation}). ` +
77
+ `Update MCP Sentinel to ${MIN_SENTINEL_COMPONENTS_VERSION} or later. ` +
78
+ "No write was attempted, and no direct paragraph write was tried: it would change the live page.",
79
+ );
80
+ }
81
+
82
+ /**
83
+ * One `/mcp-draft` request carrying node attributes and components.
84
+ * @param {object} backend
85
+ * @param {object} spec
86
+ * @returns {Promise<object>}
87
+ */
88
+ function draftRequest(backend, { entityType, bundle, id, data, components, live, working, langcode, preflight }) {
89
+ return backend.rawQuery({
90
+ path: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
91
+ options: {
92
+ method: "PATCH",
93
+ headers: {
94
+ "If-Match": working ? `"${live}:${working}"` : `"${live}"`,
95
+ "X-MCP-Draft-Preflight": preflight ? "1" : "0",
96
+ "X-MCP-Draft-Langcode": langcode,
97
+ },
98
+ body: JSON.stringify({ data, meta: { mcp_components: components } }),
99
+ },
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Whether a stored field value carries every submitted part.
105
+ *
106
+ * Submitted objects are compared key by key, so read-only extras such as
107
+ * `processed` or `resolvable_uri` do not count. An empty submitted `options`
108
+ * matches any stored empty options. A single submitted item matches a
109
+ * single-value field read back as an object.
110
+ * @param {unknown} submitted
111
+ * @param {unknown} actual
112
+ * @returns {boolean}
113
+ */
114
+ export function valueMatches(submitted, actual) {
115
+ if (submitted === null || typeof submitted !== "object") {
116
+ if (actual !== null && typeof actual === "object" && !Array.isArray(actual) && "value" in actual) {
117
+ return valueMatches(submitted, actual.value);
118
+ }
119
+ return submitted === actual;
120
+ }
121
+ if (Array.isArray(submitted)) {
122
+ const list = Array.isArray(actual) ? actual : actual === null || actual === undefined ? [] : [actual];
123
+ return list.length === submitted.length && submitted.every((item, i) => valueMatches(item, list.at(i)));
124
+ }
125
+ if (actual === null || typeof actual !== "object") return false;
126
+ const stored = new Map(Object.entries(actual));
127
+ return Object.entries(submitted).every(([key, part]) => {
128
+ const isEmpty = (v) => v === null || v === undefined
129
+ || (Array.isArray(v) && v.length === 0)
130
+ || (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0);
131
+ if (key === "options" && isEmpty(part)) return isEmpty(stored.get(key));
132
+ return valueMatches(part, stored.get(key));
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Pins keyed by paragraph UUID.
138
+ * @param {?object} entity
139
+ * @returns {Map<string, string>}
140
+ */
141
+ function pinMap(entity) {
142
+ const pins = new Map();
143
+ for (const pin of paragraphPinsFromEntity(entity)) {
144
+ if (pin.revisionId !== null && pin.revisionId !== undefined) pins.set(pin.id, String(pin.revisionId));
145
+ }
146
+ return pins;
147
+ }
148
+
149
+ /**
150
+ * Save node attributes and component paragraph changes as one unpublished
151
+ * draft revision, opening the working copy from live when none exists.
152
+ *
153
+ * @param {object} backend JSON:API backend with Sentinel draft routes.
154
+ * @param {object} input
155
+ * @param {string} input.entityType Always "node" today.
156
+ * @param {string} input.bundle
157
+ * @param {string} input.id Node UUID.
158
+ * @param {object} input.attributes Node attributes, including moderation_state.
159
+ * @param {object} [input.relationships]
160
+ * @param {Array} input.components Entries for {@link normalizeComponents}.
161
+ * @param {string} [input.langcode] Must be the default language when given.
162
+ * @param {{dryRun?: boolean}} [options]
163
+ * @returns {Promise<object>} Preview on dryRun; otherwise the written entity
164
+ * plus `_components` (live and working pins and verified fields per
165
+ * paragraph).
166
+ */
167
+ export async function writeComponentDraft(backend, input, { dryRun = false } = {}) {
168
+ const { entityType, bundle, id, attributes = {}, relationships } = input;
169
+ const components = normalizeComponents(input.components);
170
+ if (Object.hasOwn(attributes, "path")) {
171
+ throw new Error("Aliases are not revisioned. Change the alias in a separate update; no write was attempted.");
172
+ }
173
+ const inventory = await readNodeDraftInventory(backend, { entityType, bundle, id });
174
+ if (!inventory) throw capabilityError(DRAFT_COMPONENTS_OPERATION);
175
+ const operations = Array.isArray(inventory.operations) ? inventory.operations : [];
176
+ if (!operations.includes(DRAFT_COMPONENTS_OPERATION)) throw capabilityError(DRAFT_COMPONENTS_OPERATION);
177
+
178
+ const defaultLang = typeof inventory.defaultLangcode === "string" ? inventory.defaultLangcode : undefined;
179
+ if (!defaultLang) throw new Error("Sentinel did not report the default language. No write was attempted.");
180
+ if (input.langcode !== undefined && input.langcode !== null && input.langcode !== ""
181
+ && assertDraftLangcode(input.langcode) !== defaultLang) {
182
+ throw new Error(`Component changes apply to the default language (${defaultLang}) only. No write was attempted.`);
183
+ }
184
+
185
+ const live = String(inventory.live.vid);
186
+ const working = inventory.working?.vid && String(inventory.working.vid) !== live
187
+ ? String(inventory.working.vid)
188
+ : "";
189
+ if (!working && !operations.includes(OPEN_DRAFT_OPERATION)) throw capabilityError(OPEN_DRAFT_OPERATION);
190
+ if (working) {
191
+ const row = (inventory.working.translations ?? []).find((r) => r?.langcode === defaultLang);
192
+ const state = typeof row?.moderation_state === "string" ? row.moderation_state : "";
193
+ if (!row || row.status === true || state === "published") {
194
+ throw new Error(
195
+ `The default language (${defaultLang}) is still published on the working copy, so it cannot be continued. ` +
196
+ "Publish or discard the other language's draft first. No write was attempted.",
197
+ );
198
+ }
199
+ }
200
+
201
+ const data = { type: `${entityType}--${bundle}`, id, attributes };
202
+ if (relationships && Object.keys(relationships).length) data.relationships = relationships;
203
+ const request = { entityType, bundle, id, data, components, live, working, langcode: defaultLang };
204
+
205
+ const checked = await draftRequest(backend, { ...request, preflight: true });
206
+ const meta = checked?.meta;
207
+ if (meta?.draft_preflight !== true || String(meta.live) !== live || String(meta.working ?? "") !== working
208
+ || (!working && meta.operation !== OPEN_DRAFT_OPERATION)) {
209
+ throw new Error("The site did not confirm a non-saving draft preflight for these components. No write was attempted.");
210
+ }
211
+ if (dryRun) {
212
+ return {
213
+ dryRun: true, operation: "update", entityType, bundle, id, attributes,
214
+ relationships: data.relationships ?? {}, components,
215
+ opensWorkingCopy: !working,
216
+ ...dryRunChecks({ operation: "update", preflight: PREFLIGHT_SENTINEL_DRAFT }),
217
+ };
218
+ }
219
+
220
+ // The pins each component held before this save. Continuing a working copy
221
+ // starts from that copy's pins, not live's.
222
+ const baselineEntity = working
223
+ ? await backend.getEntity({ entityType, bundle, id, resourceVersion: `id:${working}` })
224
+ : null;
225
+
226
+ const result = await draftRequest(backend, { ...request, preflight: false });
227
+ if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
228
+ throw new Error("The draft write response did not identify the node. The outcome is uncertain; re-read before retrying.");
229
+ }
230
+ const entity = backend.toCanonical(result.data);
231
+ const workingVid = entityRevisionId(entity) ?? entityRevisionId(result.data);
232
+ if (workingVid === null) {
233
+ throw new Error("The draft write response carried no revision ID. Re-read the working copy before retrying.");
234
+ }
235
+ const [liveEntity, workingEntity] = await Promise.all([
236
+ backend.getEntity({ entityType, bundle, id }),
237
+ backend.getEntity({ entityType, bundle, id, resourceVersion: `id:${workingVid}` }),
238
+ ]);
239
+ const livePins = pinMap(liveEntity);
240
+ const workingPins = pinMap(workingEntity);
241
+ const baselinePins = working ? pinMap(baselineEntity) : livePins;
242
+ // Every paragraph on the host gets a new revision when the host does, so a
243
+ // moved pin alone proves nothing. Read each component at its working pin and
244
+ // compare the submitted values.
245
+ const report = [];
246
+ for (const { id: uuid, type, attributes: submitted } of components) {
247
+ const livePin = livePins.get(uuid) ?? null;
248
+ const workingPin = workingPins.get(uuid) ?? null;
249
+ const baselinePin = baselinePins.get(uuid) ?? null;
250
+ if (!workingPin || workingPin === livePin || workingPin === baselinePin) {
251
+ throw new Error(
252
+ `The draft was saved as revision ${workingVid}, but it still pins the earlier revision of component ${uuid}. ` +
253
+ "Re-read the working copy before retrying.",
254
+ );
255
+ }
256
+ const paragraph = await backend.getEntity({
257
+ entityType: "paragraph", bundle: type.slice("paragraph--".length), id: uuid, resourceVersion: `id:${workingPin}`,
258
+ });
259
+ if (!paragraph) {
260
+ throw new Error(
261
+ `The draft was saved as revision ${workingVid}, but component ${uuid} could not read back at revision ${workingPin}. ` +
262
+ "Check the working copy before retrying.",
263
+ );
264
+ }
265
+ const values = new Map(Object.entries({ ...(paragraph.attributes ?? {}), ...(paragraph.fields ?? {}) }));
266
+ const mismatched = Object.entries(submitted)
267
+ .filter(([name, value]) => !valueMatches(value, values.get(name)))
268
+ .map(([name]) => name);
269
+ if (mismatched.length) {
270
+ throw new Error(
271
+ `The draft was saved as revision ${workingVid}, but component ${uuid} does not hold the submitted ` +
272
+ `${mismatched.join(", ")} at revision ${workingPin}. Check the working copy before retrying.`,
273
+ );
274
+ }
275
+ report.push({ id: uuid, type, livePin, workingPin, verified: Object.keys(submitted) });
276
+ }
277
+ // `_`-prefixed keys survive `returning: "minimal"`.
278
+ return {
279
+ ...entity,
280
+ _revisions: { live, working: workingVid },
281
+ _components: report,
282
+ };
283
+ }
@@ -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 nested paragraphs and paragraphs the node does not reference. 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
  },