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.
@@ -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 };
@@ -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
- * 2. Drush `config:get field.field.{entityType}.{bundle}.{field}`, then
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
- * 3. If the definition cannot be resolved: return null. Creates keep the
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) return parseFieldConfigObject({
141
- field_name: def.fieldName,
142
- field_type: def.fieldType,
143
- settings: { allowed_formats: def.allowedFormats },
144
- }, fieldName) ?? def;
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 Drush next.
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 (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
256
- const format = new Map(Object.entries(value)).get("format");
257
- return typeof format === "string" && format && format !== "0" ? format : undefined;
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" && !Array.isArray(value)) {
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
- * @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object, existingEntity?: ?object}} input
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" && (typeof value !== "object" || Array.isArray(value))) continue;
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
- const formattedType = Boolean(def?.fieldType && FORMATTED_FIELD_TYPES.has(def.fieldType));
320
- const restricted = Boolean(def?.allowedFormats?.length);
321
- const isBody = fieldName === "body";
322
- const shaped = isFormattedShape(value);
323
-
324
- if (def && !formattedType && !restricted && !isBody) continue;
325
- if (!def && !shaped && !isBody) continue;
326
-
327
- const allowedFormats = def ? def.allowedFormats : null;
328
- const listUnknown = !Array.isArray(allowedFormats) || allowedFormats.length === 0;
329
- let requested = requestedFormatOf(value);
330
- if ((requested === undefined || requested === null || requested === "") && listUnknown) {
331
- const stored = storedTextFormat(existingEntity, fieldName);
332
- if (stored) requested = stored;
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
+ }