drupal-mcp-connector 3.1.0 → 3.2.1
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 +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +21 -2
- package/package.json +7 -7
- package/src/index.js +3 -0
- package/src/lib/backends/jsonapi.js +22 -6
- package/src/lib/canonical-draft.js +795 -0
- package/src/lib/component-draft.js +24 -2
- package/src/lib/dispatch.js +7 -0
- package/src/lib/field-definition.js +146 -41
- package/src/lib/nested-draft.js +1420 -0
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +34 -0
- package/src/lib/text-format-context.js +248 -0
- package/src/lib/write-revision.js +66 -16
- package/src/tools/bulk.js +17 -12
- package/src/tools/entities.js +16 -14
- package/src/tools/index.js +2 -1
- package/src/tools/media.js +9 -2
- package/src/tools/nested.js +69 -0
- package/src/tools/nodes.js +2 -2
- package/src/tools/paragraphs.js +12 -3
- package/src/tools/translations.js +5 -1
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readNodeDraftInventory, assertDraftLangcode } from "./sentinel-draft.js";
|
|
17
17
|
import { entityRevisionId } from "./write-revision.js";
|
|
18
18
|
import { paragraphPinsFromEntity } from "./err-relationships.js";
|
|
19
|
+
import { applyParagraphTextFormats } from "./field-definition.js";
|
|
19
20
|
import { PREFLIGHT_SENTINEL_DRAFT, dryRunChecks } from "./dry-run-checks.js";
|
|
20
21
|
|
|
21
22
|
/** Inventory operation: component paragraph changes inside a draft save. */
|
|
@@ -159,14 +160,19 @@ function pinMap(entity) {
|
|
|
159
160
|
* @param {object} [input.relationships]
|
|
160
161
|
* @param {Array} input.components Entries for {@link normalizeComponents}.
|
|
161
162
|
* @param {string} [input.langcode] Must be the default language when given.
|
|
162
|
-
* @param {{dryRun?: boolean}} [options]
|
|
163
|
+
* @param {{dryRun?: boolean, site?: object}} [options]
|
|
163
164
|
* @returns {Promise<object>} Preview on dryRun; otherwise the written entity
|
|
164
165
|
* plus `_components` (live and working pins and verified fields per
|
|
165
166
|
* paragraph).
|
|
166
167
|
*/
|
|
167
|
-
export async function writeComponentDraft(backend, input, { dryRun = false } = {}) {
|
|
168
|
+
export async function writeComponentDraft(backend, input, { dryRun = false, site = {} } = {}) {
|
|
168
169
|
const { entityType, bundle, id, attributes = {}, relationships } = input;
|
|
169
170
|
const components = normalizeComponents(input.components);
|
|
171
|
+
// Refuse a disallowed paragraph format before inventory or the draft preflight.
|
|
172
|
+
for (const component of components) {
|
|
173
|
+
const paragraphBundle = component.type.slice("paragraph--".length);
|
|
174
|
+
await applyParagraphTextFormats(backend, site, paragraphBundle, component.attributes);
|
|
175
|
+
}
|
|
170
176
|
if (Object.hasOwn(attributes, "path")) {
|
|
171
177
|
throw new Error("Aliases are not revisioned. Change the alias in a separate update; no write was attempted.");
|
|
172
178
|
}
|
|
@@ -198,6 +204,22 @@ export async function writeComponentDraft(backend, input, { dryRun = false } = {
|
|
|
198
204
|
}
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
const pinHost = working
|
|
208
|
+
? await backend.getEntity({ entityType, bundle, id, resourceVersion: `id:${working}` })
|
|
209
|
+
: await backend.getEntity({ entityType, bundle, id });
|
|
210
|
+
if (!pinHost) {
|
|
211
|
+
throw new Error("The host revision could not be read. No write was attempted, and no direct paragraph write was tried.");
|
|
212
|
+
}
|
|
213
|
+
const directIds = new Set(paragraphPinsFromEntity(pinHost).map((pin) => pin.id.toLowerCase()));
|
|
214
|
+
for (const component of components) {
|
|
215
|
+
if (!directIds.has(component.id.toLowerCase())) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${component.id} is not a direct paragraph of this host. Nested paragraphs are not component edits. ` +
|
|
218
|
+
"Use drupal_draft_nested_components. No write was attempted, and no direct paragraph write was tried."
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
201
223
|
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
202
224
|
if (relationships && Object.keys(relationships).length) data.relationships = relationships;
|
|
203
225
|
const request = { entityType, bundle, id, data, components, live, working, langcode: defaultLang };
|
package/src/lib/dispatch.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolveSecurityConfig, assertNotReadOnly,
|
|
|
15
15
|
import { toolError, toolResult } from "./errors.js";
|
|
16
16
|
import { BackendCapabilityError, BackendResolutionError } from "./backends/errors.js";
|
|
17
17
|
import { inferOperation } from "./operations.js";
|
|
18
|
+
import { ensureTextFormatContext, toolNeedsTextFormatContext } from "./text-format-context.js";
|
|
18
19
|
import { assertSourceGovernance, GovernanceError } from "./governance.js";
|
|
19
20
|
import {
|
|
20
21
|
assertPrincipalEntitlement, callerTargetHints, DIAGNOSTIC_TOOLS,
|
|
@@ -186,6 +187,12 @@ export async function securityMiddleware(toolName, args, handler, context = {})
|
|
|
186
187
|
});
|
|
187
188
|
return runWithDataFlow(flow, async () => {
|
|
188
189
|
await assertCallAllowed();
|
|
190
|
+
// Content-tier tokens cannot read field_config. Load Sentinel's schema
|
|
191
|
+
// before a handler that resolves text formats (#429). Other writes do
|
|
192
|
+
// not wait. A miss leaves the historical unknown-list path in place.
|
|
193
|
+
if (toolNeedsTextFormatContext(toolName)) {
|
|
194
|
+
await ensureTextFormatContext(site);
|
|
195
|
+
}
|
|
189
196
|
// Charge only after governance and policy gates pass, so an outage or a
|
|
190
197
|
// local deny cannot exhaust the window. Diagnostics do not consume.
|
|
191
198
|
if (!DIAGNOSTIC_TOOLS.has(toolName)) {
|
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
* `base_field_override` (node body is a base field; its allowed_formats
|
|
12
12
|
* are not on `field_config`). Internal adapter fetch, not
|
|
13
13
|
* `drupal_entity_get` (`field_config` is on the agent deny list).
|
|
14
|
-
*
|
|
14
|
+
* A row whose `field_name` is not the requested field is a miss.
|
|
15
|
+
* 2. Cached Sentinel `GET /drupal-mcp/context`, for node bundles, when the
|
|
16
|
+
* field entry includes `allowed_formats` (#429). A content-tier token
|
|
17
|
+
* cannot read `field_config`. Dispatch fills this cache before a tool
|
|
18
|
+
* that resolves text formats.
|
|
19
|
+
* 3. Drush `config:get field.field.{entityType}.{bundle}.{field}`, then
|
|
15
20
|
* `core.base_field_override.{entityType}.{bundle}.{field}`, when a Drush
|
|
16
21
|
* bridge is configured.
|
|
17
|
-
*
|
|
22
|
+
* 4. If the definition cannot be resolved: return null. Creates keep the
|
|
18
23
|
* historical default chain only while the list is unknown. Updates reuse
|
|
19
24
|
* the format already stored on that field (#327) so a dry run cannot
|
|
20
25
|
* preview a fallback the save will reject. Once the list is known, never
|
|
@@ -22,6 +27,7 @@
|
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import { drushConfigured } from "./audit-sources.js";
|
|
30
|
+
import { textFormatDefinitionFromContext } from "./text-format-context.js";
|
|
25
31
|
import { validateMachineName } from "./validate.js";
|
|
26
32
|
import { parseDrush, sshDrush } from "../tools/drush.js";
|
|
27
33
|
|
|
@@ -137,15 +143,19 @@ export async function resolveFieldDefinition(backend, site, entityType, bundle,
|
|
|
137
143
|
if (typeof backend?.getFieldDefinition === "function") {
|
|
138
144
|
try {
|
|
139
145
|
const def = await backend.getFieldDefinition({ entityType, bundle, fieldName });
|
|
140
|
-
if (def)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
if (def?.fieldName === fieldName) {
|
|
147
|
+
return parseFieldConfigObject({
|
|
148
|
+
field_name: def.fieldName,
|
|
149
|
+
field_type: def.fieldType,
|
|
150
|
+
settings: { allowed_formats: def.allowedFormats },
|
|
151
|
+
}, fieldName) ?? def;
|
|
152
|
+
}
|
|
145
153
|
} catch {
|
|
146
|
-
// JSON:API field_config is optional; try
|
|
154
|
+
// JSON:API field_config is optional; try the next source.
|
|
147
155
|
}
|
|
148
156
|
}
|
|
157
|
+
const fromContext = textFormatDefinitionFromContext(site, entityType, bundle, fieldName);
|
|
158
|
+
if (fromContext) return fromContext;
|
|
149
159
|
if (drushConfigured(site)) {
|
|
150
160
|
const def = await fieldDefinitionFromDrush(site, entityType, bundle, fieldName);
|
|
151
161
|
if (def) return def;
|
|
@@ -242,8 +252,19 @@ function requestedFormatOf(value) {
|
|
|
242
252
|
return undefined;
|
|
243
253
|
}
|
|
244
254
|
|
|
255
|
+
/**
|
|
256
|
+
* @param {*} value
|
|
257
|
+
* @returns {string|undefined}
|
|
258
|
+
*/
|
|
259
|
+
function storedFormatName(value) {
|
|
260
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
261
|
+
const format = new Map(Object.entries(value)).get("format");
|
|
262
|
+
return typeof format === "string" && format && format !== "0" ? format : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
245
265
|
/**
|
|
246
266
|
* Format Drupal already stored for a field, if the canonical entity has one.
|
|
267
|
+
* A one-element list is the JSON:API shape of a single-value text field.
|
|
247
268
|
* @param {?object} entity
|
|
248
269
|
* @param {string} fieldName
|
|
249
270
|
* @returns {string|undefined}
|
|
@@ -252,9 +273,31 @@ function storedTextFormat(entity, fieldName) {
|
|
|
252
273
|
const fields = entity?.fields;
|
|
253
274
|
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return undefined;
|
|
254
275
|
const value = new Map(Object.entries(fields)).get(fieldName);
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
if (value.length !== 1) return undefined;
|
|
278
|
+
return storedFormatName(value.at(0));
|
|
279
|
+
}
|
|
280
|
+
return storedFormatName(value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A one-element JSON:API list omits its format when the only item is a
|
|
285
|
+
* string or a `{ value }` object without `format`. A longer list of bare
|
|
286
|
+
* strings is a list field, not an omitted text format. A formatted item
|
|
287
|
+
* inside a longer list still counts.
|
|
288
|
+
* @param {unknown[]} value
|
|
289
|
+
* @returns {boolean}
|
|
290
|
+
*/
|
|
291
|
+
function arrayOmitsTextFormat(value) {
|
|
292
|
+
if (value.length === 1) {
|
|
293
|
+
const item = value.at(0);
|
|
294
|
+
if (typeof item === "string") return true;
|
|
295
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
|
|
296
|
+
const hasValue = Object.prototype.hasOwnProperty.call(item, "value");
|
|
297
|
+
const hasFormat = Object.prototype.hasOwnProperty.call(item, "format") && item.format;
|
|
298
|
+
return hasValue && !hasFormat;
|
|
299
|
+
}
|
|
300
|
+
return value.some((item) => isFormattedShape(item) && !requestedFormatOf(item));
|
|
258
301
|
}
|
|
259
302
|
|
|
260
303
|
/**
|
|
@@ -267,8 +310,12 @@ export function attributesOmitTextFormat(attributes) {
|
|
|
267
310
|
if (!attributes || typeof attributes !== "object") return false;
|
|
268
311
|
for (const [name, value] of Object.entries(attributes)) {
|
|
269
312
|
if (SKIP_FORMAT_FIELDS.has(name) || value === undefined || value === null) continue;
|
|
313
|
+
if (Array.isArray(value)) {
|
|
314
|
+
if (arrayOmitsTextFormat(value)) return true;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
270
317
|
if (typeof value === "string") return true;
|
|
271
|
-
if (typeof value === "object"
|
|
318
|
+
if (typeof value === "object") {
|
|
272
319
|
const hasValue = Object.prototype.hasOwnProperty.call(value, "value");
|
|
273
320
|
const hasFormat = Object.prototype.hasOwnProperty.call(value, "format") && value.format;
|
|
274
321
|
if ((name === "body" || hasValue) && !hasFormat) return true;
|
|
@@ -294,6 +341,44 @@ function normalizeFormattedValue(raw, format) {
|
|
|
294
341
|
return out;
|
|
295
342
|
}
|
|
296
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Resolve one formatted value, or return it unchanged when it is not text.
|
|
346
|
+
* @param {string} fieldName
|
|
347
|
+
* @param {*} value
|
|
348
|
+
* @param {?object} def
|
|
349
|
+
* @param {boolean} treatAsBody
|
|
350
|
+
* @param {object} site
|
|
351
|
+
* @param {?object} existingEntity
|
|
352
|
+
* @returns {*}
|
|
353
|
+
*/
|
|
354
|
+
function resolveFormattedItem(fieldName, value, def, treatAsBody, site, existingEntity) {
|
|
355
|
+
const formattedType = Boolean(def?.fieldType && FORMATTED_FIELD_TYPES.has(def.fieldType));
|
|
356
|
+
const restricted = Boolean(def?.allowedFormats?.length);
|
|
357
|
+
const shaped = isFormattedShape(value);
|
|
358
|
+
if (typeof value !== "string" && (typeof value !== "object" || value === null || Array.isArray(value))) {
|
|
359
|
+
return value;
|
|
360
|
+
}
|
|
361
|
+
if (def && !formattedType && !restricted && !treatAsBody) return value;
|
|
362
|
+
if (!def && !shaped && !treatAsBody) return value;
|
|
363
|
+
|
|
364
|
+
const allowedFormats = def ? def.allowedFormats : null;
|
|
365
|
+
const listUnknown = !Array.isArray(allowedFormats) || allowedFormats.length === 0;
|
|
366
|
+
let requested = requestedFormatOf(value);
|
|
367
|
+
if ((requested === undefined || requested === null || requested === "") && listUnknown) {
|
|
368
|
+
const stored = storedTextFormat(existingEntity, fieldName);
|
|
369
|
+
if (stored) requested = stored;
|
|
370
|
+
}
|
|
371
|
+
const format = resolveTextFormat({
|
|
372
|
+
fieldName,
|
|
373
|
+
requested,
|
|
374
|
+
allowedFormats,
|
|
375
|
+
site,
|
|
376
|
+
defaultWhenUnknown: treatAsBody,
|
|
377
|
+
});
|
|
378
|
+
if (format === undefined && !formattedType && !treatAsBody && !shaped) return value;
|
|
379
|
+
return normalizeFormattedValue(value, format);
|
|
380
|
+
}
|
|
381
|
+
|
|
297
382
|
/**
|
|
298
383
|
* Default and validate text formats on a write attribute map (mutates it).
|
|
299
384
|
* Same checks run for dry-run and real writes so a disallowed format never
|
|
@@ -303,46 +388,66 @@ function normalizeFormattedValue(raw, format) {
|
|
|
303
388
|
* caller omitted a format, the format already stored on that field is reused.
|
|
304
389
|
* The same choice is what dryRun previews and what the save sends (#327).
|
|
305
390
|
*
|
|
306
|
-
*
|
|
391
|
+
* `defaultBodyFormat` is true for the node tools, which keep the historical
|
|
392
|
+
* body fallback when the list is unknown. Other write tools pass false so a
|
|
393
|
+
* string body is not rewritten to `full_html`.
|
|
394
|
+
*
|
|
395
|
+
* A one-element array is checked too (#429). Callers sometimes send the
|
|
396
|
+
* JSON:API list shape for a single-value text field; skipping the array let
|
|
397
|
+
* a disallowed format through. An update that omits the format on that
|
|
398
|
+
* one-element shape reuses the stored format when the allowed list is unknown.
|
|
399
|
+
*
|
|
400
|
+
* @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object, existingEntity?: ?object, defaultBodyFormat?: boolean}} input
|
|
307
401
|
* @returns {Promise<object>} The same attributes object, with formats resolved.
|
|
308
402
|
*/
|
|
309
403
|
export async function applyAllowedFormatsToAttributes({
|
|
310
404
|
backend, site, entityType, bundle, attributes, existingEntity = null,
|
|
405
|
+
defaultBodyFormat = true,
|
|
311
406
|
}) {
|
|
312
407
|
const names = Object.keys(attributes).filter((name) => !SKIP_FORMAT_FIELDS.has(name));
|
|
313
408
|
for (const fieldName of names) {
|
|
314
409
|
const value = new Map(Object.entries(attributes)).get(fieldName);
|
|
315
410
|
if (value === undefined || value === null) continue;
|
|
316
|
-
if (typeof value !== "string" &&
|
|
317
|
-
|
|
411
|
+
if (!Array.isArray(value) && typeof value !== "string" && typeof value !== "object") continue;
|
|
412
|
+
const treatAsBody = defaultBodyFormat && fieldName === "body";
|
|
318
413
|
const def = await resolveFieldDefinition(backend, site, entityType, bundle, fieldName);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
|
|
414
|
+
if (Array.isArray(value)) {
|
|
415
|
+
const formattedType = Boolean(def?.fieldType && FORMATTED_FIELD_TYPES.has(def.fieldType));
|
|
416
|
+
const restricted = Boolean(def?.allowedFormats?.length);
|
|
417
|
+
const anyShaped = value.some((item) => isFormattedShape(item));
|
|
418
|
+
if (!formattedType && !restricted && !treatAsBody && !anyShaped) continue;
|
|
419
|
+
const next = value.map((item) => resolveFormattedItem(
|
|
420
|
+
fieldName, item, def, treatAsBody, site, existingEntity,
|
|
421
|
+
));
|
|
422
|
+
Object.assign(attributes, Object.fromEntries([[fieldName, next]]));
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (typeof value !== "string" && typeof value !== "object") continue;
|
|
426
|
+
const next = resolveFormattedItem(fieldName, value, def, treatAsBody, site, existingEntity);
|
|
427
|
+
if (next !== value) {
|
|
428
|
+
Object.assign(attributes, Object.fromEntries([[fieldName, next]]));
|
|
333
429
|
}
|
|
334
|
-
const format = resolveTextFormat({
|
|
335
|
-
fieldName,
|
|
336
|
-
requested,
|
|
337
|
-
allowedFormats,
|
|
338
|
-
site,
|
|
339
|
-
defaultWhenUnknown: isBody,
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
if (format === undefined && !formattedType && !isBody && !shaped) continue;
|
|
343
|
-
Object.assign(attributes, Object.fromEntries([
|
|
344
|
-
[fieldName, normalizeFormattedValue(value, format)],
|
|
345
|
-
]));
|
|
346
430
|
}
|
|
347
431
|
return attributes;
|
|
348
432
|
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Validate text formats on caller-supplied paragraph attributes.
|
|
436
|
+
* Paragraph writes do not invent `full_html`. Do not pass attributes copied
|
|
437
|
+
* from an existing paragraph: those already have the stored format.
|
|
438
|
+
* @param {object} backend
|
|
439
|
+
* @param {object} site
|
|
440
|
+
* @param {string} bundle Paragraph bundle machine name.
|
|
441
|
+
* @param {object} attributes Mutated in place.
|
|
442
|
+
* @returns {Promise<object>}
|
|
443
|
+
*/
|
|
444
|
+
export function applyParagraphTextFormats(backend, site, bundle, attributes) {
|
|
445
|
+
return applyAllowedFormatsToAttributes({
|
|
446
|
+
backend,
|
|
447
|
+
site,
|
|
448
|
+
entityType: "paragraph",
|
|
449
|
+
bundle,
|
|
450
|
+
attributes,
|
|
451
|
+
defaultBodyFormat: false,
|
|
452
|
+
});
|
|
453
|
+
}
|