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.
- package/.agents/commands/drupal-update-node.md +2 -1
- package/CHANGELOG.md +26 -0
- package/README.md +14 -0
- package/package.json +2 -2
- package/src/lib/component-draft.js +283 -0
- package/src/tools/nodes.js +31 -1
|
@@ -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
|
|
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
|
+
}
|
package/src/tools/nodes.js
CHANGED
|
@@ -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
|
},
|