drupal-mcp-connector 3.2.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/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.2.1] - 2026-10-07
11
+
12
+ ### Fixed
13
+ - **Text formats from Sentinel context (#429).** Node writes read
14
+ `allowed_formats` from `GET /drupal-mcp/context` when JSON:API field
15
+ config is not readable. A present list is enforced, including a
16
+ one-element `{ value, format }` array. A missing key stays an unknown
17
+ list, so an older Sentinel still passes an explicit format through.
18
+ Entity, translation, bulk, paragraph, and media writes use the same
19
+ check and do not invent `full_html` for a string body. A JSON:API row
20
+ whose field name does not match the request is ignored. A context miss
21
+ is remembered for 30 seconds, and only tools that resolve text formats
22
+ wait on that document. Component drafts and nested paragraph children,
23
+ including their translations, are checked before preflight or create.
24
+ An update that sends a one-element text array without a format reuses
25
+ the stored format. Hosts stay on the previous behavior until Sentinel
26
+ 2.32.0, which emits the key, is deployed.
27
+
28
+ ### Added
29
+ - **Consumers pin a version and check advisories before upgrade (#423).**
30
+ The README tells operators to pin an exact version, confirm the registry
31
+ integrity checksum, read the changelog, and run `npm audit --omit=dev`
32
+ before upgrading. No separate scanner. The install and `npx` examples,
33
+ and the post-upgrade doctor command, name that same version.
34
+
10
35
  ## [3.2.0] - 2026-10-05
11
36
 
12
37
  ### Added
package/README.md CHANGED
@@ -278,8 +278,8 @@ the allowlist or Sentinel profile excludes the type — run `doctor` and
278
278
  ### From npm (recommended for operators)
279
279
 
280
280
  ```bash
281
- npm install -g drupal-mcp-connector
282
- # or: npx -y drupal-mcp-connector
281
+ npm install -g drupal-mcp-connector@3.2.1
282
+ # or: npx -y drupal-mcp-connector@3.2.1
283
283
  ```
284
284
 
285
285
  Point your MCP client at the installed binary (path from `which drupal-mcp-connector`
@@ -287,6 +287,25 @@ or `node_modules/.bin/drupal-mcp-connector`). For multi-site or non-env config,
287
287
  clone the repo (or copy `config/config.example.json` beside a small launcher) and
288
288
  pass a config path — see **[docs/getting-started.md](docs/getting-started.md)**.
289
289
 
290
+ ### Before you upgrade
291
+
292
+ Pin the version you run. Do not point a client you depend on at `latest` or an
293
+ unbounded range.
294
+
295
+ 1. Pin an exact version in the lockfile (`package-lock.json` or
296
+ `npm-shrinkwrap.json`), or in `package.json` as
297
+ `"drupal-mcp-connector": "3.2.1"` (use the version you intend to run).
298
+ 2. Confirm the registry checksum before you install.
299
+ `npm view drupal-mcp-connector@3.2.1 dist.integrity` prints the integrity
300
+ npm checks. A lockfile install fails when the tarball does not match.
301
+ 3. Read [CHANGELOG.md](CHANGELOG.md) for that version.
302
+ 4. Run `npm audit --omit=dev` against the lockfile and read any advisory
303
+ before you upgrade. There is no separate scanner to install.
304
+
305
+ Restart the MCP client after the upgrade. Run `drupal-mcp-doctor` when the
306
+ package is on your PATH, or `npx -y drupal-mcp-connector@3.2.1 doctor` when
307
+ you launch it with npx.
308
+
290
309
  ### From a git clone (development)
291
310
 
292
311
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
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",
package/src/index.js CHANGED
@@ -50,6 +50,7 @@ import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-
50
50
  import { createConnectorServerFactory } from "./lib/mcp-server.js";
51
51
  import { createRateLimiter } from "./lib/rate-limit.js";
52
52
  import { callTool, invokeReadTool, listResolvableSiteConfigs } from "./lib/dispatch.js";
53
+ import { fetchTextFormatContext, installTextFormatContextFetcher } from "./lib/text-format-context.js";
53
54
  import { filterDiscoverableTools } from "./lib/governance.js";
54
55
  import {
55
56
  filterPromptsByPrincipal,
@@ -75,6 +76,8 @@ import { builtinWorkflowProvider } from "./lib/workflows/builtin.js";
75
76
  import { runWizardCli } from "./lib/wizard.js";
76
77
  import { runDoctorCli } from "./lib/doctor.js";
77
78
 
79
+ installTextFormatContextFetcher(fetchTextFormatContext);
80
+
78
81
  const cliCommand = process.argv[2];
79
82
  if (cliCommand === "wizard" || cliCommand === "init") {
80
83
  process.exit(await runWizardCli(process.argv.slice(3)));
@@ -884,7 +884,10 @@ export class JsonApiBackend extends Backend {
884
884
  try {
885
885
  const data = await drupalFetch(this.site, `/jsonapi/${resource}/${resource}?${params}`);
886
886
  const row = Array.isArray(data?.data) ? data.data[0] : data?.data;
887
- return parseFieldConfigObject(row?.attributes, fieldName);
887
+ // A filter that is ignored would return some other field. Using that
888
+ // row's allowed_formats would persist the wrong format (#429).
889
+ if (row?.attributes?.field_name !== fieldName) return null;
890
+ return parseFieldConfigObject(row.attributes, fieldName);
888
891
  } catch {
889
892
  return null;
890
893
  }
@@ -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
  }
@@ -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
+ }
@@ -21,6 +21,7 @@ import { entityRevisionId } from "./write-revision.js";
21
21
  import { embedParagraphRef, paragraphRevisionId } from "./err-relationships.js";
22
22
  import { httpStatusOf } from "./error-status.js";
23
23
  import { OPEN_DRAFT_OPERATION, MIN_SENTINEL_COMPONENTS_VERSION, valueMatches } from "./component-draft.js";
24
+ import { applyParagraphTextFormats } from "./field-definition.js";
24
25
  import { PREFLIGHT_NONE, PREFLIGHT_SENTINEL_DRAFT, dryRunChecks } from "./dry-run-checks.js";
25
26
 
26
27
  export const NESTED_DRAFT_PARTIAL_CODE = "NESTED_DRAFT_PARTIAL";
@@ -84,11 +85,14 @@ export function nestedDraftUncertain(detail, prepared) {
84
85
  /**
85
86
  * @param {object} backend
86
87
  * @param {object} input
87
- * @param {{dryRun?: boolean, assertParagraphCreate?: (bundle: string) => void}} [options]
88
+ * @param {{dryRun?: boolean, assertParagraphCreate?: (bundle: string) => void, site?: object}} [options]
88
89
  * @returns {Promise<object>}
89
90
  */
90
- export async function draftNestedComponents(backend, input, { dryRun = false, assertParagraphCreate } = {}) {
91
+ export async function draftNestedComponents(backend, input, { dryRun = false, assertParagraphCreate, site = {} } = {}) {
91
92
  const spec = normalizeNestedInput(input);
93
+ // Caller-supplied children and translations only. Copied parent fields are
94
+ // the stored paragraph, not a new format choice, and are left unchanged.
95
+ await applyCallerParagraphFormats(backend, site, spec.children);
92
96
  const inventory = await readNodeDraftInventory(backend, spec);
93
97
  if (!inventory) {
94
98
  throw new Error(
@@ -730,9 +734,21 @@ function assertChildren(children, childPins, requiredLangs) {
730
734
  }
731
735
 
732
736
  /**
733
- * @param {object} child
734
- * @param {Set<string>} requiredLangs
737
+ * @param {object} backend
738
+ * @param {object} site
739
+ * @param {object[]} children
740
+ * @returns {Promise<void>}
735
741
  */
742
+ async function applyCallerParagraphFormats(backend, site, children) {
743
+ for (const child of children) {
744
+ if (child.op === "keep") continue;
745
+ await applyParagraphTextFormats(backend, site, child.type, child.attributes);
746
+ for (const translation of child.translations ?? []) {
747
+ await applyParagraphTextFormats(backend, site, child.type, translation.attributes);
748
+ }
749
+ }
750
+ }
751
+
736
752
  function assertLangs(child, requiredLangs) {
737
753
  const supplied = new Set(child.translations.map((row) => row.langcode));
738
754
  for (const lang of requiredLangs) {
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Text-format restrictions from MCP Sentinel's site schema (#429).
3
+ *
4
+ * A content-tier token cannot read JSON:API `field_config` (that needs
5
+ * "administer node fields") and this connector's production configs often
6
+ * have no Drush bridge. Sentinel's `GET /drupal-mcp/context` is the schema
7
+ * that token can read. When a field entry includes `allowed_formats`, that
8
+ * list is Field API's restriction: one entry is the default, any other
9
+ * format is refused. A missing key means an older Sentinel that does not
10
+ * report the restriction — the caller must not treat that as "all formats".
11
+ * An empty array means the field has no format restriction.
12
+ *
13
+ * Dispatch fills the process cache before a tool that resolves text formats.
14
+ * A failed fetch is remembered only briefly so a Sentinel outage does not
15
+ * add the timeout to every later write, and concurrent writes share one
16
+ * request. Unit tests that call handlers directly can seed a successful
17
+ * document with `rememberTextFormatContext`.
18
+ */
19
+
20
+ import { authHeadersAsync, clientHeaders } from "./config.js";
21
+
22
+ /** How long a successful context document is reused. */
23
+ const CONTEXT_TTL_MS = 10 * 60 * 1000;
24
+
25
+ /** How long a miss is remembered. Short, so a recovered Sentinel is retried. */
26
+ const FAILURE_TTL_MS = 30 * 1000;
27
+
28
+ /** Context fetch budget. A miss must not stall the write past this. */
29
+ const CONTEXT_TIMEOUT_MS = 8000;
30
+
31
+ /**
32
+ * Tools whose handlers call the text-format resolver. Other writes must not
33
+ * wait on this document.
34
+ */
35
+ const TEXT_FORMAT_TOOLS = new Set([
36
+ "drupal_create_node",
37
+ "drupal_update_node",
38
+ "drupal_entity_create",
39
+ "drupal_entity_update",
40
+ "drupal_create_translation",
41
+ "drupal_bulk_create",
42
+ "drupal_bulk_update",
43
+ "drupal_create_paragraph",
44
+ "drupal_update_paragraph",
45
+ "drupal_create_media",
46
+ "drupal_update_media",
47
+ "drupal_draft_nested_components",
48
+ ]);
49
+
50
+ /** @type {Map<string, {at: number, document: ?object, ttl: number}>} */
51
+ const cache = new Map();
52
+
53
+ /** @type {Map<string, {slot: object, promise: Promise<void>}>} */
54
+ const inflight = new Map();
55
+
56
+ /** @type {(site: object) => Promise<?object>} */
57
+ let fetcher = async () => null;
58
+
59
+ /**
60
+ * Cache key for one configured site. Name and base URL both matter so two
61
+ * sites that share a name in different processes cannot collide here, and a
62
+ * renamed base URL does not reuse the previous document.
63
+ * @param {object} site Resolved site config.
64
+ * @returns {string}
65
+ */
66
+ function cacheKey(site) {
67
+ return `${site?._name ?? ""}\0${site?.baseUrl ?? ""}`;
68
+ }
69
+
70
+ /**
71
+ * Drop cached schema documents. Tests call this between cases.
72
+ * @returns {void}
73
+ */
74
+ export function clearTextFormatContextCache() {
75
+ cache.clear();
76
+ inflight.clear();
77
+ }
78
+
79
+ /**
80
+ * Whether this tool resolves text formats and should wait for context.
81
+ * @param {string} toolName
82
+ * @returns {boolean}
83
+ */
84
+ export function toolNeedsTextFormatContext(toolName) {
85
+ return TEXT_FORMAT_TOOLS.has(toolName);
86
+ }
87
+
88
+ /**
89
+ * Seed the cache without a network call. A seeded document uses the success TTL.
90
+ * @param {object} site Resolved site config.
91
+ * @param {object} document Sentinel context JSON.
92
+ * @returns {void}
93
+ */
94
+ export function rememberTextFormatContext(site, document) {
95
+ cache.set(cacheKey(site), { at: Date.now(), document, ttl: CONTEXT_TTL_MS });
96
+ }
97
+
98
+ /**
99
+ * @param {string} key
100
+ * @returns {boolean}
101
+ */
102
+ function fresh(key) {
103
+ const hit = cache.get(key);
104
+ if (!hit) return false;
105
+ return Date.now() - hit.at < hit.ttl;
106
+ }
107
+
108
+ /**
109
+ * Install the process fetcher. The server entry calls this with
110
+ * {@link fetchTextFormatContext}. Tests leave the default, which returns
111
+ * null and does not touch the network.
112
+ * @param {(site: object) => Promise<?object>} next
113
+ * @returns {void}
114
+ */
115
+ export function installTextFormatContextFetcher(next) {
116
+ fetcher = next;
117
+ }
118
+
119
+ /**
120
+ * Load `GET /drupal-mcp/context` when the cache is cold. A failure is cached
121
+ * as a miss for 30 seconds: the write keeps the historical path
122
+ * for an unknown format list, and the next write does not wait again.
123
+ * Concurrent callers share one in-flight request.
124
+ * @param {object} site Resolved site config.
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function ensureTextFormatContext(site) {
128
+ if (!site?.baseUrl) return;
129
+ const key = cacheKey(site);
130
+ if (fresh(key)) return;
131
+ const pending = inflight.get(key);
132
+ if (pending) {
133
+ await pending.promise;
134
+ return;
135
+ }
136
+ // Identity is a plain object. Comparing the promise itself is what CodeQL
137
+ // reports as a missing await.
138
+ const slot = {};
139
+ const promise = loadTextFormatContext(site, key);
140
+ inflight.set(key, { slot, promise });
141
+ try {
142
+ await promise;
143
+ } finally {
144
+ const row = inflight.get(key);
145
+ if (row?.slot === slot) inflight.delete(key);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * @param {object} site
151
+ * @param {string} key
152
+ * @returns {Promise<void>}
153
+ */
154
+ async function loadTextFormatContext(site, key) {
155
+ let document = null;
156
+ try {
157
+ const loaded = await fetcher(site);
158
+ if (loaded && typeof loaded === "object" && !Array.isArray(loaded)) document = loaded;
159
+ } catch {
160
+ document = null;
161
+ }
162
+ cache.set(key, {
163
+ at: Date.now(),
164
+ document,
165
+ ttl: document ? CONTEXT_TTL_MS : FAILURE_TTL_MS,
166
+ });
167
+ }
168
+
169
+ /**
170
+ * GET /drupal-mcp/context as the site's own principal.
171
+ * @param {object} site Resolved site config.
172
+ * @returns {Promise<?object>} The JSON document, or null on any miss.
173
+ */
174
+ export async function fetchTextFormatContext(site) {
175
+ let headers;
176
+ try {
177
+ headers = {
178
+ Accept: "application/json",
179
+ ...clientHeaders(),
180
+ ...(await authHeadersAsync(site)),
181
+ };
182
+ } catch {
183
+ return null;
184
+ }
185
+ let res;
186
+ try {
187
+ res = await fetch(`${site.baseUrl}/drupal-mcp/context`, {
188
+ method: "GET",
189
+ headers,
190
+ signal: AbortSignal.timeout(CONTEXT_TIMEOUT_MS),
191
+ });
192
+ } catch {
193
+ return null;
194
+ }
195
+ if (!res.ok) return null;
196
+ try {
197
+ const body = await res.json();
198
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
199
+ if (!Object.prototype.hasOwnProperty.call(body, "content_types")) return null;
200
+ return body;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Field definition from a cached context document.
208
+ * Only node bundles are present. A field with no `allowed_formats` key is
209
+ * unknown (older Sentinel), not unrestricted.
210
+ * @param {object} site Resolved site config.
211
+ * @param {string} entityType
212
+ * @param {string} bundle
213
+ * @param {string} fieldName
214
+ * @returns {?{fieldName: string, fieldType: ?string, allowedFormats: string[]}}
215
+ */
216
+ export function textFormatDefinitionFromContext(site, entityType, bundle, fieldName) {
217
+ if (entityType !== "node") return null;
218
+ const hit = cache.get(cacheKey(site));
219
+ // A cached miss (document null) is not a schema. Do not invent a list.
220
+ if (!hit?.document || !fresh(cacheKey(site))) return null;
221
+ const types = hit.document?.content_types;
222
+ if (!types || typeof types !== "object" || Array.isArray(types)) return null;
223
+ const schema = new Map(Object.entries(types)).get(bundle);
224
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return null;
225
+ const fields = new Map(Object.entries(schema)).get("fields");
226
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) return null;
227
+ const field = new Map(Object.entries(fields)).get(fieldName);
228
+ if (!field || typeof field !== "object" || Array.isArray(field)) return null;
229
+ const attrs = new Map(Object.entries(field));
230
+ if (!attrs.has("allowed_formats")) return null;
231
+ const fieldType = attrs.get("type");
232
+ return {
233
+ fieldName,
234
+ fieldType: typeof fieldType === "string" ? fieldType : null,
235
+ allowedFormats: normalizeContextFormats(attrs.get("allowed_formats")),
236
+ };
237
+ }
238
+
239
+ /**
240
+ * @param {*} raw `allowed_formats` from the context document.
241
+ * @returns {string[]}
242
+ */
243
+ function normalizeContextFormats(raw) {
244
+ const values = Array.isArray(raw)
245
+ ? raw
246
+ : (raw && typeof raw === "object" ? [...new Map(Object.entries(raw)).values()] : []);
247
+ return values.filter((value) => typeof value === "string" && value !== "" && value !== "0");
248
+ }
package/src/tools/bulk.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  resolveParagraphRevisionId, missingParagraphRevisionError,
22
22
  } from "../lib/err-relationships.js";
23
23
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
24
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
24
25
 
25
26
  /**
26
27
  * Normalize an unknown thrown value into a human-readable message.
@@ -53,11 +54,15 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
53
54
  for (const [index, rawItem] of items.entries()) {
54
55
  const item = rawItem || {};
55
56
  try {
56
- assertPublishAllowed(sec, item.attributes ?? {});
57
+ const formatted = await applyAllowedFormatsToAttributes({
58
+ backend, site, entityType, bundle, attributes: { ...(item.attributes ?? {}) },
59
+ defaultBodyFormat: false,
60
+ });
61
+ assertPublishAllowed(sec, formatted);
57
62
  const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
58
63
  const entity = await backend.createEntity({
59
64
  entityType, bundle,
60
- attributes: withUnpublishedDefault(sec, entityType, item.attributes ?? {}),
65
+ attributes: withUnpublishedDefault(sec, entityType, formatted),
61
66
  relationships: resolvedRelationships,
62
67
  });
63
68
  created += 1;
@@ -107,9 +112,13 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
107
112
  const existing = await readUpdateTarget(backend, {
108
113
  entityType, bundle, id: item.id, attributes: item.attributes ?? {},
109
114
  });
115
+ const formatted = await applyAllowedFormatsToAttributes({
116
+ backend, site, entityType, bundle, attributes: { ...(item.attributes ?? {}) },
117
+ existingEntity: existing, defaultBodyFormat: false,
118
+ });
110
119
  const attributes = await applySafeDraftDefault({
111
120
  backend, entityType, bundle, id: item.id,
112
- attributes: item.attributes ?? {},
121
+ attributes: formatted,
113
122
  existingEntity: existing,
114
123
  });
115
124
  assertPublishAllowed(sec, attributes);
@@ -19,6 +19,7 @@ import {
19
19
  import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
21
  import { readUpdateTarget } from "../lib/canonical-draft.js";
22
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
22
23
  import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
23
24
  import {
24
25
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
@@ -70,9 +71,12 @@ async function createEntity({ site: siteName, entityType, bundle, attributes: ca
70
71
  const site = getSiteConfig(siteName);
71
72
  const sec = resolveSecurityConfig(site);
72
73
  assertWriteAllowed(sec, "create", entityType, bundle);
73
- assertPublishAllowed(sec, callerAttributes);
74
- const attributes = withUnpublishedDefault(sec, entityType, callerAttributes);
75
74
  const backend = await resolveBackend(site);
75
+ const formatted = await applyAllowedFormatsToAttributes({
76
+ backend, site, entityType, bundle, attributes: { ...callerAttributes }, defaultBodyFormat: false,
77
+ });
78
+ assertPublishAllowed(sec, formatted);
79
+ const attributes = withUnpublishedDefault(sec, entityType, formatted);
76
80
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
77
81
  if (dryRun) {
78
82
  return {
@@ -113,8 +117,12 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
113
117
  // unrequested-status-change flag. Skipped when the caller pinned the
114
118
  // moderation state explicitly (same condition the draft default uses).
115
119
  const existing = await readUpdateTarget(backend, { entityType, bundle, id, attributes });
120
+ const formatted = await applyAllowedFormatsToAttributes({
121
+ backend, site, entityType, bundle, attributes: { ...attributes }, existingEntity: existing,
122
+ defaultBodyFormat: false,
123
+ });
116
124
  const safeAttributes = await applySafeDraftDefault({
117
- backend, entityType, bundle, id, attributes, existingEntity: existing,
125
+ backend, entityType, bundle, id, attributes: formatted, existingEntity: existing,
118
126
  });
119
127
  assertPublishAllowed(sec, safeAttributes);
120
128
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
@@ -19,6 +19,7 @@ import {
19
19
  } from "../lib/sentinel-draft.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
21
  import { entityRevisionId } from "../lib/write-revision.js";
22
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
22
23
 
23
24
  /**
24
25
  * List all media types (bundles of the media entity type).
@@ -110,8 +111,11 @@ async function createMedia({ site: siteName, type, name, status = false, fields
110
111
  // Layer name/status after fields so they win, matching prior behaviour.
111
112
  attributes.name = name;
112
113
  attributes.status = status;
113
- assertPublishAllowed(sec, attributes);
114
114
  const backend = await resolveBackend(site);
115
+ await applyAllowedFormatsToAttributes({
116
+ backend, site, entityType: "media", bundle: type, attributes, defaultBodyFormat: false,
117
+ });
118
+ assertPublishAllowed(sec, attributes);
115
119
  return backend.createEntity({
116
120
  entityType: "media", bundle: type, attributes,
117
121
  ...(relationships ? { relationships } : {}),
@@ -134,8 +138,11 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
134
138
  const { attributes, relationships } = splitReferenceFields(fields);
135
139
  if (name !== undefined) attributes.name = name;
136
140
  if (status !== undefined) attributes.status = status;
137
- assertPublishAllowed(sec, attributes);
138
141
  const backend = await resolveBackend(site);
142
+ await applyAllowedFormatsToAttributes({
143
+ backend, site, entityType: "media", bundle: type, attributes, defaultBodyFormat: false,
144
+ });
145
+ assertPublishAllowed(sec, attributes);
139
146
  if (langcode) {
140
147
  const targetLang = assertDraftLangcode(langcode);
141
148
  const patchTarget = await prepareGuardedPatch(backend, {
@@ -29,6 +29,7 @@ async function draftNestedComponentsTool({
29
29
  liveRevisionId, resumeParentId,
30
30
  }, {
31
31
  dryRun,
32
+ site,
32
33
  assertParagraphCreate(paragraphBundle) {
33
34
  assertWriteAllowed(sec, "create", "paragraph", paragraphBundle);
34
35
  },
@@ -516,7 +516,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
516
516
  const written = await writeComponentDraft(backend, {
517
517
  entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
518
518
  components: normalizedComponents, langcode,
519
- }, { dryRun });
519
+ }, { dryRun, site });
520
520
  const shaped = dryRun ? written : shapeWriteResponse(written, returning);
521
521
  return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(shaped) : shaped;
522
522
  }
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import { getSiteConfig } from "../lib/config.js";
38
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
38
39
  import { resolveBackend } from "../lib/backends/index.js";
39
40
  import {
40
41
  resolveSecurityConfig, assertWriteAllowed, assertReadAllowed, redactCanonicalEntity,
@@ -93,7 +94,11 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
93
94
  const sec = resolveSecurityConfig(site);
94
95
  assertWriteAllowed(sec, "create", "paragraph", paragraphType);
95
96
  const backend = await resolveBackend(site);
96
- const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes });
97
+ const formatted = await applyAllowedFormatsToAttributes({
98
+ backend, site, entityType: "paragraph", bundle: paragraphType,
99
+ attributes: { ...attributes }, defaultBodyFormat: false,
100
+ });
101
+ const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes: formatted });
97
102
  const bundle = paragraph.bundle || paragraphType;
98
103
  const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
99
104
  if (revisionId === null) throw missingParagraphRevisionError(paragraph.id);
@@ -122,6 +127,10 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
122
127
  const sec = resolveSecurityConfig(site);
123
128
  assertWriteAllowed(sec, "update", "paragraph", paragraphType);
124
129
  const backend = await resolveBackend(site);
130
+ const formatted = await applyAllowedFormatsToAttributes({
131
+ backend, site, entityType: "paragraph", bundle: paragraphType,
132
+ attributes: { ...attributes }, defaultBodyFormat: false,
133
+ });
125
134
  let paragraph;
126
135
  if (langcode) {
127
136
  const targetLang = assertDraftLangcode(langcode);
@@ -132,11 +141,11 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
132
141
  throw new Error("Paragraph translation update requires a paragraph revision ID (the host pin).");
133
142
  }
134
143
  paragraph = await writeDraft(backend, {
135
- entityType: "paragraph", bundle: paragraphType, id, attributes, langcode: targetLang, draftState,
144
+ entityType: "paragraph", bundle: paragraphType, id, attributes: formatted, langcode: targetLang, draftState,
136
145
  draftRevision: { revisionId: pinned },
137
146
  });
138
147
  } else {
139
- paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
148
+ paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes: formatted });
140
149
  }
141
150
  const bundle = paragraph.bundle || paragraphType;
142
151
  const resolvedRevisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
@@ -28,6 +28,7 @@ import {
28
28
  } from "../lib/security.js";
29
29
  import { validateUuid, validateMachineName } from "../lib/validate.js";
30
30
  import { applySafeDraftDefault } from "../lib/moderation-default.js";
31
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
31
32
  import { omitLiveComputedMetatag } from "../lib/entity-response.js";
32
33
  import { entityRevisionId } from "../lib/write-revision.js";
33
34
  import { paragraphRevisionId } from "../lib/err-relationships.js";
@@ -160,7 +161,10 @@ async function createTranslation({
160
161
  }
161
162
 
162
163
  let draftRevision;
163
- let drafted = { ...attributes };
164
+ let drafted = await applyAllowedFormatsToAttributes({
165
+ backend, site, entityType, bundle: type, attributes: { ...attributes }, existingEntity: existing,
166
+ defaultBodyFormat: false,
167
+ });
164
168
  delete drafted.langcode;
165
169
  if (entityType === "paragraph") {
166
170
  const pinned = revisionId ?? paragraphRevisionId(existing);