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.
- package/.agents/commands/drupal-draft-nested-components.md +26 -0
- package/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +2 -1
- package/CHANGELOG.md +87 -0
- package/README.md +14 -0
- package/package.json +8 -8
- package/src/lib/backends/jsonapi.js +18 -5
- package/src/lib/canonical-draft.js +795 -0
- package/src/lib/component-draft.js +299 -0
- package/src/lib/nested-draft.js +1404 -0
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +34 -0
- package/src/lib/write-revision.js +66 -16
- package/src/tools/bulk.js +5 -9
- package/src/tools/entities.js +5 -11
- package/src/tools/index.js +2 -1
- package/src/tools/nested.js +68 -0
- package/src/tools/nodes.js +31 -1
|
@@ -0,0 +1,299 @@
|
|
|
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 pinHost = working
|
|
202
|
+
? await backend.getEntity({ entityType, bundle, id, resourceVersion: `id:${working}` })
|
|
203
|
+
: await backend.getEntity({ entityType, bundle, id });
|
|
204
|
+
if (!pinHost) {
|
|
205
|
+
throw new Error("The host revision could not be read. No write was attempted, and no direct paragraph write was tried.");
|
|
206
|
+
}
|
|
207
|
+
const directIds = new Set(paragraphPinsFromEntity(pinHost).map((pin) => pin.id.toLowerCase()));
|
|
208
|
+
for (const component of components) {
|
|
209
|
+
if (!directIds.has(component.id.toLowerCase())) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`${component.id} is not a direct paragraph of this host. Nested paragraphs are not component edits. ` +
|
|
212
|
+
"Use drupal_draft_nested_components. No write was attempted, and no direct paragraph write was tried."
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
218
|
+
if (relationships && Object.keys(relationships).length) data.relationships = relationships;
|
|
219
|
+
const request = { entityType, bundle, id, data, components, live, working, langcode: defaultLang };
|
|
220
|
+
|
|
221
|
+
const checked = await draftRequest(backend, { ...request, preflight: true });
|
|
222
|
+
const meta = checked?.meta;
|
|
223
|
+
if (meta?.draft_preflight !== true || String(meta.live) !== live || String(meta.working ?? "") !== working
|
|
224
|
+
|| (!working && meta.operation !== OPEN_DRAFT_OPERATION)) {
|
|
225
|
+
throw new Error("The site did not confirm a non-saving draft preflight for these components. No write was attempted.");
|
|
226
|
+
}
|
|
227
|
+
if (dryRun) {
|
|
228
|
+
return {
|
|
229
|
+
dryRun: true, operation: "update", entityType, bundle, id, attributes,
|
|
230
|
+
relationships: data.relationships ?? {}, components,
|
|
231
|
+
opensWorkingCopy: !working,
|
|
232
|
+
...dryRunChecks({ operation: "update", preflight: PREFLIGHT_SENTINEL_DRAFT }),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The pins each component held before this save. Continuing a working copy
|
|
237
|
+
// starts from that copy's pins, not live's.
|
|
238
|
+
const baselineEntity = working
|
|
239
|
+
? await backend.getEntity({ entityType, bundle, id, resourceVersion: `id:${working}` })
|
|
240
|
+
: null;
|
|
241
|
+
|
|
242
|
+
const result = await draftRequest(backend, { ...request, preflight: false });
|
|
243
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
244
|
+
throw new Error("The draft write response did not identify the node. The outcome is uncertain; re-read before retrying.");
|
|
245
|
+
}
|
|
246
|
+
const entity = backend.toCanonical(result.data);
|
|
247
|
+
const workingVid = entityRevisionId(entity) ?? entityRevisionId(result.data);
|
|
248
|
+
if (workingVid === null) {
|
|
249
|
+
throw new Error("The draft write response carried no revision ID. Re-read the working copy before retrying.");
|
|
250
|
+
}
|
|
251
|
+
const [liveEntity, workingEntity] = await Promise.all([
|
|
252
|
+
backend.getEntity({ entityType, bundle, id }),
|
|
253
|
+
backend.getEntity({ entityType, bundle, id, resourceVersion: `id:${workingVid}` }),
|
|
254
|
+
]);
|
|
255
|
+
const livePins = pinMap(liveEntity);
|
|
256
|
+
const workingPins = pinMap(workingEntity);
|
|
257
|
+
const baselinePins = working ? pinMap(baselineEntity) : livePins;
|
|
258
|
+
// Every paragraph on the host gets a new revision when the host does, so a
|
|
259
|
+
// moved pin alone proves nothing. Read each component at its working pin and
|
|
260
|
+
// compare the submitted values.
|
|
261
|
+
const report = [];
|
|
262
|
+
for (const { id: uuid, type, attributes: submitted } of components) {
|
|
263
|
+
const livePin = livePins.get(uuid) ?? null;
|
|
264
|
+
const workingPin = workingPins.get(uuid) ?? null;
|
|
265
|
+
const baselinePin = baselinePins.get(uuid) ?? null;
|
|
266
|
+
if (!workingPin || workingPin === livePin || workingPin === baselinePin) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`The draft was saved as revision ${workingVid}, but it still pins the earlier revision of component ${uuid}. ` +
|
|
269
|
+
"Re-read the working copy before retrying.",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const paragraph = await backend.getEntity({
|
|
273
|
+
entityType: "paragraph", bundle: type.slice("paragraph--".length), id: uuid, resourceVersion: `id:${workingPin}`,
|
|
274
|
+
});
|
|
275
|
+
if (!paragraph) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`The draft was saved as revision ${workingVid}, but component ${uuid} could not read back at revision ${workingPin}. ` +
|
|
278
|
+
"Check the working copy before retrying.",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const values = new Map(Object.entries({ ...(paragraph.attributes ?? {}), ...(paragraph.fields ?? {}) }));
|
|
282
|
+
const mismatched = Object.entries(submitted)
|
|
283
|
+
.filter(([name, value]) => !valueMatches(value, values.get(name)))
|
|
284
|
+
.map(([name]) => name);
|
|
285
|
+
if (mismatched.length) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`The draft was saved as revision ${workingVid}, but component ${uuid} does not hold the submitted ` +
|
|
288
|
+
`${mismatched.join(", ")} at revision ${workingPin}. Check the working copy before retrying.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
report.push({ id: uuid, type, livePin, workingPin, verified: Object.keys(submitted) });
|
|
292
|
+
}
|
|
293
|
+
// `_`-prefixed keys survive `returning: "minimal"`.
|
|
294
|
+
return {
|
|
295
|
+
...entity,
|
|
296
|
+
_revisions: { live, working: workingVid },
|
|
297
|
+
_components: report,
|
|
298
|
+
};
|
|
299
|
+
}
|