drupal-mcp-connector 2.23.3 → 2.24.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.
@@ -17,7 +17,7 @@ Parse the arguments supplied with this command into this tool's parameters:
17
17
  - `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
18
18
  - `body` (string): Body field HTML
19
19
  - `summary` (string): Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`.
20
- - `format` (string): Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'.
20
+ - `format` (string): Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. allowed_formats is read from field_config, then base_field_override (node body). When that list cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'.
21
21
  - `status` (boolean (true/false)): Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically.
22
22
  - `moderationState` (string): Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status.
23
23
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by Drupal machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`.
@@ -18,13 +18,13 @@ Parse the arguments supplied with this command into this tool's parameters:
18
18
  - `title` (string)
19
19
  - `body` (string)
20
20
  - `summary` (string): Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`.
21
- - `format` (string): Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'.
21
+ - `format` (string): Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before the preview and the write. allowed_formats is read from field_config, then base_field_override (node body). When that list cannot be resolved, an update reuses the format already stored on the field; if the node has none, the site config's `defaultTextFormat`, then 'full_html'. dryRun previews that same format, including on the published-node core PATCH guard.
22
22
  - `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
23
23
  - `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'.
24
24
  - `langcode` (string): Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode.
25
25
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here.
26
26
  - `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused.
27
- - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun.
27
+ - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun.
28
28
  - `returning` (one of: full, minimal): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
29
29
 
30
30
  If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.24.1] - 2026-09-29
11
+
12
+ ### Removed
13
+ - **Unused validate helpers and stale workflow-prompt exports.** `clampLimit` /
14
+ `MAX_PAGE_LIMIT` and `validateFieldName` / `FIELD_NAME_RE` had no callers.
15
+ `workflowIndex`, `WORKFLOW_LIMITS`, and the unused `toolNameToPromptName`
16
+ re-export are gone. Filter/sort field names stay unvalidated so JSON:API
17
+ relationship paths keep working; `URLSearchParams` remains the T3 control.
18
+
19
+ ## [2.24.0] - 2026-09-29
20
+
21
+ ### Added
22
+ - **Paragraphs Library items on the content tiers.** `paragraphs_library_item`
23
+ joins the structural content set, so the `content-editor`, `config-editor`
24
+ and `write-plane` presets can create and edit reusable library items through
25
+ `drupal_entity_*`. Deletes stay blocked. A write carrying
26
+ `moderation_state: published` or `status: true` is refused when
27
+ `allowPublish` is false, as for every other type; drafts pass. On a site
28
+ that moderates library items, Drupal's workflow is the second gate.
29
+
30
+ ### Fixed
31
+ - **A library item created by a tier that cannot publish stays unpublished.**
32
+ Drupal publishes Paragraphs Library items by default. On a site that does
33
+ not moderate them, `drupal_entity_create` or `drupal_bulk_create` without
34
+ `status` put the item live on every page that places it, although
35
+ `allowPublish` was false. When publishing is not allowed and the caller sets
36
+ neither `status` nor `moderation_state`, the connector now sends
37
+ `status: false`. Other entity types are unchanged.
38
+ - **Node update preflight no longer accepts a body format the save rejects (#327).**
39
+ Node body is a base field. Its `allowed_formats` live on
40
+ `base_field_override`, which field lookup did not read, so an omitted format
41
+ fell back to `defaultTextFormat` / `full_html`. On a published moderated node
42
+ with no working copy, `dryRun` only probes core's working-copy guard and does
43
+ not send fields, so the preview succeeded and the save returned 422
44
+ `body.0.format is not a valid choice`. Lookup now reads
45
+ `base_field_override` (JSON:API, then Drush
46
+ `core.base_field_override.*`). When the allowed list is still unknown, an
47
+ update reuses the format already stored on the field. The preview and the
48
+ save resolve that same format, including on the core PATCH-guard path.
49
+
10
50
  ## [2.23.3] - 2026-09-28
11
51
 
12
52
  ### Fixed
package/README.md CHANGED
@@ -55,12 +55,13 @@ Each site declares which backend(s) it exposes via the `api` key:
55
55
  - **`defaultTextFormat` sets the body text format** used by the `body` convenience
56
56
  parameter on node writes, e.g. `{ "baseUrl": "…", "defaultTextFormat": "basic_html" }`.
57
57
  Individual calls can override it with `format`. Field API `allowed_formats` wins
58
- when it can be resolved: a single allowed format is the default, and a format
59
- outside the list is refused. Without a resolved list, the connector falls
60
- back to `full_html`, which many governed sites deliberately do not define — and which
61
- is the most permissive core format, so setting this per site is the safer posture. A
62
- text format is Drupal's HTML-filtering boundary, so it is worth choosing deliberately
63
- rather than inheriting.
58
+ when it can be resolved (from `field_config` or, for body, `base_field_override`):
59
+ a single allowed format is the default, and a format outside the list is refused.
60
+ Without a resolved list, a create falls back to `full_html`, which many governed
61
+ sites deliberately do not define — and which is the most permissive core format,
62
+ so setting this per site is the safer posture. An update reuses the format already
63
+ stored on the field instead of that fallback. A text format is Drupal's
64
+ HTML-filtering boundary, so it is worth choosing deliberately rather than inheriting.
64
65
 
65
66
  See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction and **[docs/graphql-local-setup.md](docs/graphql-local-setup.md)** for the GraphQL specifics.
66
67
 
@@ -146,7 +146,7 @@
146
146
 
147
147
  "_security_presets": {
148
148
  "development": "All operations allowed, incl. config read/write. Loopback / break-glass only.",
149
- "content-editor": "Create/edit the full content set (node, media, taxonomy_term, paragraph, block_content, menu_link_content, redirect, path_alias, file). No deletes. Config read-only. Denies user + secrets/governance types.",
149
+ "content-editor": "Create/edit the full content set (node, media, taxonomy_term, paragraph, paragraphs_library_item, block_content, menu_link_content, redirect, path_alias, file). No deletes. Config read-only. Denies user + secrets/governance types.",
150
150
  "config-editor": "content-editor + site-building config entities (read/introspection) + governed config read/write (Developer tier). Model changes go via the config bridge, not JSON:API.",
151
151
  "auditor": "Read-only. All entity types. User PII fields redacted. Config read-only.",
152
152
  "production-strict": "Read-only. No user entities. Broad PII redaction. No config access.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.23.3",
3
+ "version": "2.24.1",
4
4
  "description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -151,10 +151,11 @@ export class Backend {
151
151
  * Resolve Field API metadata for one field (internal introspection).
152
152
  *
153
153
  * `getEntitySchema` is sampling-only and does NOT expose `allowed_formats`.
154
- * The JSON:API adapter reads `field_config` via an internal fetch — not via
155
- * agent entity tools (`field_config` is on the agent deny list). Returns
156
- * null when the definition cannot be resolved. Callers must not invent
157
- * `allowed_formats` from `defaultTextFormat` or `full_html`.
154
+ * The JSON:API adapter reads `field_config`, then `base_field_override`,
155
+ * via an internal fetch — not via agent entity tools (`field_config` is on
156
+ * the agent deny list). Returns null when the definition cannot be resolved.
157
+ * Callers must not invent `allowed_formats` from `defaultTextFormat` or
158
+ * `full_html`.
158
159
  *
159
160
  * Default: unknown. GraphQL is read-only for writes; write sites use JSON:API.
160
161
  *
@@ -729,15 +729,17 @@ export class JsonApiBackend extends Backend {
729
729
  }
730
730
 
731
731
  /**
732
- * Read Field API metadata from JSON:API `field_config` (chain step 1).
732
+ * Read Field API metadata from JSON:API (chain step 1).
733
733
  *
734
+ * Configurable fields live on `field_config`. Node body is a base field:
735
+ * its `allowed_formats` live on `base_field_override`, not `field_config`.
734
736
  * This is an internal introspection call, not an agent entity-tool read:
735
737
  * `field_config` is on the connector deny list. Do not invent
736
738
  * `allowed_formats` from `this.site.defaultTextFormat` or `full_html`.
737
- * Empty / unreadable `field_config` returns null so the caller can try
738
- * Drush `config:get field.field.{entityType}.{bundle}.{field}` and, if
739
- * that also fails, keep the historical default chain only while the list
740
- * is unknown.
739
+ * Empty / unreadable rows return null so the caller can try Drush
740
+ * `config:get` (`field.field.*`, then `core.base_field_override.*`) and,
741
+ * if that also fails, keep the historical default chain only while the
742
+ * list is unknown. Updates reuse a stored format in that case (#327).
741
743
  *
742
744
  * @param {{entityType: string, bundle: string, fieldName: string}} ref
743
745
  * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
@@ -790,14 +792,19 @@ export class JsonApiBackend extends Backend {
790
792
  params.set("filter[bundle]", bundle);
791
793
  params.set("filter[field_name]", fieldName);
792
794
  params.set("page[limit]", "1");
793
- let data;
794
- try {
795
- data = await drupalFetch(this.site, `/jsonapi/field_config/field_config?${params}`);
796
- } catch {
797
- return null;
798
- }
799
- const row = Array.isArray(data?.data) ? data.data[0] : data?.data;
800
- return parseFieldConfigObject(row?.attributes, fieldName);
795
+ const read = async (resource) => {
796
+ try {
797
+ const data = await drupalFetch(this.site, `/jsonapi/${resource}/${resource}?${params}`);
798
+ const row = Array.isArray(data?.data) ? data.data[0] : data?.data;
799
+ return parseFieldConfigObject(row?.attributes, fieldName);
800
+ } catch {
801
+ return null;
802
+ }
803
+ };
804
+ // A configurable-field row is authoritative, including an empty
805
+ // allowed_formats list (no field-level restriction). Body is absent
806
+ // from field_config, so the base-field override is the next read.
807
+ return (await read("field_config")) ?? (await read("base_field_override"));
801
808
  }
802
809
 
803
810
  /**
@@ -7,14 +7,18 @@
7
7
  * `FALLBACK_TEXT_FORMAT` (`full_html`).
8
8
  *
9
9
  * Resolution chain for a field definition:
10
- * 1. `backend.getFieldDefinition` — JSON:API `field_config` via an internal
11
- * adapter fetch (not `drupal_entity_get`; `field_config` is on the agent
12
- * deny list).
13
- * 2. Drush `config:get field.field.{entityType}.{bundle}.{field}` when a
14
- * Drush bridge is configured.
15
- * 3. If the definition cannot be resolved: return null. Callers keep the
16
- * historical default chain only while the list is unknown. Once the list
17
- * is known, never persist a format outside it.
10
+ * 1. `backend.getFieldDefinition` — JSON:API `field_config`, then
11
+ * `base_field_override` (node body is a base field; its allowed_formats
12
+ * are not on `field_config`). Internal adapter fetch, not
13
+ * `drupal_entity_get` (`field_config` is on the agent deny list).
14
+ * 2. Drush `config:get field.field.{entityType}.{bundle}.{field}`, then
15
+ * `core.base_field_override.{entityType}.{bundle}.{field}`, when a Drush
16
+ * bridge is configured.
17
+ * 3. If the definition cannot be resolved: return null. Creates keep the
18
+ * historical default chain only while the list is unknown. Updates reuse
19
+ * the format already stored on that field (#327) so a dry run cannot
20
+ * preview a fallback the save will reject. Once the list is known, never
21
+ * persist a format outside it.
18
22
  */
19
23
 
20
24
  import { drushConfigured } from "./audit-sources.js";
@@ -36,10 +40,16 @@ const SKIP_FORMAT_FIELDS = new Set([
36
40
  * @param {*} raw settings.allowed_formats from field_config / Drush.
37
41
  * @returns {string[]}
38
42
  */
43
+ function isEnabledFormatId(value) {
44
+ // Field UI checkboxes store a disabled format as 0 or "0", and an enabled
45
+ // format as its machine name. "0" is a non-empty string but not a format.
46
+ return typeof value === "string" && value !== "" && value !== "0";
47
+ }
48
+
39
49
  export function asFormatList(raw) {
40
- if (Array.isArray(raw)) return raw.filter((f) => typeof f === "string" && f);
50
+ if (Array.isArray(raw)) return raw.filter(isEnabledFormatId);
41
51
  if (raw && typeof raw === "object") {
42
- return [...new Map(Object.entries(raw)).values()].filter((f) => typeof f === "string" && f);
52
+ return [...new Map(Object.entries(raw)).values()].filter(isEnabledFormatId);
43
53
  }
44
54
  return [];
45
55
  }
@@ -98,13 +108,20 @@ export async function fieldDefinitionFromDrush(site, entityType, bundle, fieldNa
98
108
  validateMachineName(entityType, "entityType");
99
109
  validateMachineName(bundle, "bundle");
100
110
  validateMachineName(fieldName, "fieldName");
101
- const configName = `field.field.${entityType}.${bundle}.${fieldName}`;
102
- try {
103
- const raw = parseDrush(await sshDrush(site, ["config:get", configName, "--format=json"]));
104
- return parseFieldConfigObject(unwrapDrushConfig(raw, configName), fieldName);
105
- } catch {
106
- return null;
111
+ const configNames = [
112
+ `field.field.${entityType}.${bundle}.${fieldName}`,
113
+ `core.base_field_override.${entityType}.${bundle}.${fieldName}`,
114
+ ];
115
+ for (const configName of configNames) {
116
+ try {
117
+ const raw = parseDrush(await sshDrush(site, ["config:get", configName, "--format=json"]));
118
+ const parsed = parseFieldConfigObject(unwrapDrushConfig(raw, configName), fieldName);
119
+ if (parsed) return parsed;
120
+ } catch {
121
+ // A missing `field.field.*` row is how Drush reports a base field.
122
+ }
107
123
  }
124
+ return null;
108
125
  }
109
126
 
110
127
  /**
@@ -225,6 +242,41 @@ function requestedFormatOf(value) {
225
242
  return undefined;
226
243
  }
227
244
 
245
+ /**
246
+ * Format Drupal already stored for a field, if the canonical entity has one.
247
+ * @param {?object} entity
248
+ * @param {string} fieldName
249
+ * @returns {string|undefined}
250
+ */
251
+ function storedTextFormat(entity, fieldName) {
252
+ const fields = entity?.fields;
253
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) return undefined;
254
+ 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;
258
+ }
259
+
260
+ /**
261
+ * Whether a write attribute map omits a text format the resolver may have to
262
+ * supply. Used to decide whether an update must read the stored entity first.
263
+ * @param {?object} attributes
264
+ * @returns {boolean}
265
+ */
266
+ export function attributesOmitTextFormat(attributes) {
267
+ if (!attributes || typeof attributes !== "object") return false;
268
+ for (const [name, value] of Object.entries(attributes)) {
269
+ if (SKIP_FORMAT_FIELDS.has(name) || value === undefined || value === null) continue;
270
+ if (typeof value === "string") return true;
271
+ if (typeof value === "object" && !Array.isArray(value)) {
272
+ const hasValue = Object.prototype.hasOwnProperty.call(value, "value");
273
+ const hasFormat = Object.prototype.hasOwnProperty.call(value, "format") && value.format;
274
+ if ((name === "body" || hasValue) && !hasFormat) return true;
275
+ }
276
+ }
277
+ return false;
278
+ }
279
+
228
280
  /**
229
281
  * @param {*} raw
230
282
  * @param {string|undefined} format
@@ -247,11 +299,15 @@ function normalizeFormattedValue(raw, format) {
247
299
  * Same checks run for dry-run and real writes so a disallowed format never
248
300
  * reaches create/update.
249
301
  *
250
- * @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object}} input
302
+ * On update, pass `existingEntity`. When the allowed list is unknown and the
303
+ * caller omitted a format, the format already stored on that field is reused.
304
+ * The same choice is what dryRun previews and what the save sends (#327).
305
+ *
306
+ * @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object, existingEntity?: ?object}} input
251
307
  * @returns {Promise<object>} The same attributes object, with formats resolved.
252
308
  */
253
309
  export async function applyAllowedFormatsToAttributes({
254
- backend, site, entityType, bundle, attributes,
310
+ backend, site, entityType, bundle, attributes, existingEntity = null,
255
311
  }) {
256
312
  const names = Object.keys(attributes).filter((name) => !SKIP_FORMAT_FIELDS.has(name));
257
313
  for (const fieldName of names) {
@@ -268,10 +324,17 @@ export async function applyAllowedFormatsToAttributes({
268
324
  if (def && !formattedType && !restricted && !isBody) continue;
269
325
  if (!def && !shaped && !isBody) continue;
270
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;
333
+ }
271
334
  const format = resolveTextFormat({
272
335
  fieldName,
273
- requested: requestedFormatOf(value),
274
- allowedFormats: def ? def.allowedFormats : null,
336
+ requested,
337
+ allowedFormats,
275
338
  site,
276
339
  defaultWhenUnknown: isBody,
277
340
  });
@@ -13,7 +13,7 @@ import { parse } from "graphql";
13
13
  * ─── Quick presets ────────────────────────────────────────────────────────
14
14
  *
15
15
  * "preset": "development" Everything allowed. Opt-in only — set explicitly.
16
- * "preset": "content-editor" Create/edit content (nodes, media, terms, paragraphs, blocks,
16
+ * "preset": "content-editor" Create/edit content (nodes, media, terms, paragraphs, library items, blocks,
17
17
  * menu links, redirects, aliases, files). No deletes. Config read-only.
18
18
  * "preset": "config-editor" content-editor + site-building config READ + governed config
19
19
  * read/write (Developer tier). Model changes go via the config bridge.
@@ -99,6 +99,10 @@ import { parse } from "graphql";
99
99
  // JSON:API-writable, so the standard entity tools create/update them directly.
100
100
  const CONTENT_STRUCTURAL = [
101
101
  "paragraph",
102
+ // Paragraphs Library items: reusable paragraphs placed on many pages. On a
103
+ // site that moderates them, the Drupal workflow and allowPublish both gate
104
+ // go-live; a publish-bearing write is refused here first.
105
+ "paragraphs_library_item",
102
106
  "block_content",
103
107
  "menu_link_content",
104
108
  "redirect",
@@ -162,7 +166,8 @@ const PRESETS = {
162
166
  allowConfigRead: true, // config read-only
163
167
  allowConfigWrite: false,
164
168
  // Full content building: base content types + structural content entities
165
- // (paragraphs, custom blocks, menu links, redirects, aliases, files).
169
+ // (paragraphs, library items, custom blocks, menu links, redirects,
170
+ // aliases, files).
166
171
  allowedEntityTypes: ["node", "media", "taxonomy_term", ...CONTENT_STRUCTURAL],
167
172
  deniedEntityTypes: [...SENSITIVE_DENY],
168
173
  entityRules: {
@@ -703,6 +708,32 @@ export function assertPublishAllowed(secConfig, attributes = {}) {
703
708
  }
704
709
  }
705
710
 
711
+ // Entity types whose content is shared across many pages. When the tier
712
+ // cannot publish, a create that says nothing about publication is sent as
713
+ // unpublished instead of taking Drupal's published-by-default.
714
+ const UNPUBLISHED_BY_DEFAULT = new Set(["paragraphs_library_item"]);
715
+
716
+ /**
717
+ * Attributes for a create on a tier that cannot publish.
718
+ *
719
+ * A Paragraphs Library item is published by default in Drupal. On a site that
720
+ * does not moderate library items, a create that omits `status` would put the
721
+ * item live on every page that places it, although allowPublish is false. For
722
+ * the shared types in UNPUBLISHED_BY_DEFAULT, add `status: false` when the
723
+ * caller set neither `status` nor `moderation_state`; on a moderated site the
724
+ * workflow's default state applies as before. Other types are unchanged.
725
+ *
726
+ * @param {object} secConfig Resolved security config.
727
+ * @param {string} entityType Entity type being created.
728
+ * @param {object} attributes Caller attributes.
729
+ * @returns {object} Attributes to send.
730
+ */
731
+ export function withUnpublishedDefault(secConfig, entityType, attributes = {}) {
732
+ if (secConfig.allowPublish || !UNPUBLISHED_BY_DEFAULT.has(entityType)) return attributes;
733
+ if ("status" in attributes || "moderation_state" in attributes) return attributes;
734
+ return { ...attributes, status: false };
735
+ }
736
+
706
737
  /**
707
738
  * Detect whether a GraphQL document contains a mutation operation.
708
739
  * Uses a real parser (robust against multi-operation docs, comments, and
@@ -196,26 +196,6 @@ export function validateBaseUrl(url, siteName = "site") {
196
196
  return url.replace(/\/$/, "");
197
197
  }
198
198
 
199
- // ---------------------------------------------------------------------------
200
- // Pagination limit guard
201
- // Prevents accidentally requesting thousands of records in a single call.
202
- // ---------------------------------------------------------------------------
203
-
204
- const MAX_PAGE_LIMIT = 200;
205
-
206
- /**
207
- * Clamp a page limit to a safe maximum, falling back to a default for
208
- * non-numeric or out-of-range input.
209
- * @param {*} value Requested limit (any type; coerced to Number).
210
- * @param {number} [defaultVal] Value returned for invalid/<1 input.
211
- * @returns {number} A limit in the range [1, MAX_PAGE_LIMIT].
212
- */
213
- export function clampLimit(value, defaultVal = 20) {
214
- const n = Number(value);
215
- if (!Number.isFinite(n) || n < 1) return defaultVal;
216
- return Math.min(n, MAX_PAGE_LIMIT);
217
- }
218
-
219
199
  // ---------------------------------------------------------------------------
220
200
  // Local file upload path allowlist (#137 / audit C1)
221
201
  // ---------------------------------------------------------------------------
@@ -329,27 +309,3 @@ export function assertUploadPathAllowed(filePath) {
329
309
  }
330
310
  return real;
331
311
  }
332
-
333
- // ---------------------------------------------------------------------------
334
- // Field name sanitization (prevent crafted field names in JSON:API filters)
335
- // ---------------------------------------------------------------------------
336
-
337
- const FIELD_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
338
-
339
- /**
340
- * Validate a Drupal field machine name used in JSON:API filter parameters.
341
- * Allows dotted paths (e.g. "field.subfield") for nested references.
342
- * @param {string} value The field name to validate.
343
- * @param {string} [fieldName] Human-readable name for error messages.
344
- * @returns {string} The validated value.
345
- * @throws {Error} if the value is not a valid field name.
346
- */
347
- export function validateFieldName(value, fieldName = "field") {
348
- if (typeof value !== "string" || !FIELD_NAME_RE.test(value)) {
349
- throw new Error(
350
- `${fieldName} "${value}" is not a valid field name. ` +
351
- "Expected format: field_example or field.subfield"
352
- );
353
- }
354
- return value;
355
- }
@@ -6,11 +6,10 @@
6
6
  * docs/module-workflows.md.
7
7
  */
8
8
 
9
- import { sourceText, toolNameToPromptName } from "./tool-prompts.js";
9
+ import { sourceText } from "./tool-prompts.js";
10
10
 
11
11
  const ID_RE = /^[a-z][a-z0-9_]{0,47}$/;
12
12
  const ARG_RE = /^[a-z][a-z0-9_]{0,47}$/;
13
- const MAX_DESCRIPTION = 1024;
14
13
  const MAX_INSTRUCTIONS = 8192;
15
14
  const MAX_ARG_VALUE = 200;
16
15
  const MAX_WORKFLOWS = 64;
@@ -465,26 +464,3 @@ export function replaceModuleWorkflows(workflows) {
465
464
  export function lookupWorkflow(name) {
466
465
  return INDEX.get(name);
467
466
  }
468
-
469
- /**
470
- * Index used by tests.
471
- *
472
- * @param {object[]} workflows
473
- * @returns {Map<string, {readOnly: boolean, builtin: boolean, publicTools: string[]}>}
474
- */
475
- export function workflowIndex(workflows) {
476
- return new Map((workflows ?? []).map((wf) => [wf.name, {
477
- readOnly: wf.readOnly,
478
- builtin: wf.builtin,
479
- publicTools: wf.publicTools,
480
- }]));
481
- }
482
-
483
- export const WORKFLOW_LIMITS = {
484
- MAX_DESCRIPTION,
485
- MAX_INSTRUCTIONS,
486
- MAX_WORKFLOWS,
487
- };
488
-
489
- // Re-export for callers that already import tool prompt hyphenation.
490
- export { toolNameToPromptName };
package/src/tools/bulk.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { getSiteConfig } from "../lib/config.js";
15
15
  import { resolveBackend } from "../lib/backends/index.js";
16
- import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
16
+ import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed, withUnpublishedDefault } from "../lib/security.js";
17
17
  import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
18
18
  import {
19
19
  resolveErrRelationships, embedParagraphRef,
@@ -56,7 +56,7 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
56
56
  const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
57
57
  const entity = await backend.createEntity({
58
58
  entityType, bundle,
59
- attributes: item.attributes ?? {},
59
+ attributes: withUnpublishedDefault(sec, entityType, item.attributes ?? {}),
60
60
  relationships: resolvedRelationships,
61
61
  });
62
62
  created += 1;
@@ -21,7 +21,7 @@ import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight
21
21
  import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
22
22
  import {
23
23
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
24
- redactCanonicalEntity, getSecuritySummary,
24
+ redactCanonicalEntity, getSecuritySummary, withUnpublishedDefault,
25
25
  } from "../lib/security.js";
26
26
 
27
27
  /**
@@ -65,11 +65,12 @@ async function getEntity({ site: siteName, entityType, bundle, id, include = []
65
65
  * @returns {Promise<object>} The created entity descriptor.
66
66
  * @throws {SecurityError} If creating the type/bundle is not permitted.
67
67
  */
68
- async function createEntity({ site: siteName, entityType, bundle, attributes = {}, relationships = {}, dryRun = false, returning = "full" }) {
68
+ async function createEntity({ site: siteName, entityType, bundle, attributes: callerAttributes = {}, relationships = {}, dryRun = false, returning = "full" }) {
69
69
  const site = getSiteConfig(siteName);
70
70
  const sec = resolveSecurityConfig(site);
71
71
  assertWriteAllowed(sec, "create", entityType, bundle);
72
- assertPublishAllowed(sec, attributes);
72
+ assertPublishAllowed(sec, callerAttributes);
73
+ const attributes = withUnpublishedDefault(sec, entityType, callerAttributes);
73
74
  const backend = await resolveBackend(site);
74
75
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
75
76
  if (dryRun) {
@@ -23,7 +23,7 @@ import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } f
23
23
  import { paragraphResourceVersion } from "./paragraphs.js";
24
24
  import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
25
25
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
26
- import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
26
+ import { applyAllowedFormatsToAttributes, attributesOmitTextFormat } from "../lib/field-definition.js";
27
27
  import { normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../lib/path-alias.js";
28
28
 
29
29
  /** Fallback language for an alias when the node exposes none. */
@@ -469,19 +469,23 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
469
469
  else if (status !== undefined) attributes.status = status;
470
470
  const bodyAttr = buildBodyAttribute(body, summary, format);
471
471
  if (bodyAttr) attributes.body = bodyAttr;
472
- await applyAllowedFormatsToAttributes({
473
- backend, site, entityType: "node", bundle: type, attributes,
474
- });
475
- // One pre-read serves the #131 draft default and the #171 unrequested-
476
- // status-change flag. Skipped when the caller pinned the moderation state.
472
+ // One pre-read serves the #131 draft default, the #171 unrequested-
473
+ // status-change flag, and the stored text format (#327). Skipped when the
474
+ // caller pinned the moderation state and every formatted value already
475
+ // names its format. An omitted format still reads the entity: the published
476
+ // node's core PATCH guard does not validate fields, so the preview has to
477
+ // resolve the same format the save will send before it can report success.
477
478
  let existing = null;
478
- if (!hasExplicitModerationState(attributes)) {
479
+ if (!hasExplicitModerationState(attributes) || attributesOmitTextFormat(attributes)) {
479
480
  try {
480
481
  existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
481
482
  } catch {
482
483
  existing = null; // Unreadable target: server-side gates stay authoritative.
483
484
  }
484
485
  }
486
+ await applyAllowedFormatsToAttributes({
487
+ backend, site, entityType: "node", bundle: type, attributes, existingEntity: existing,
488
+ });
485
489
  // #131: published moderated nodes without an explicit state → draft forward revision.
486
490
  // Runs before the publish gate and on dryRun so previews match the real write.
487
491
  attributes = await applySafeDraftDefault({
@@ -632,7 +636,7 @@ export const definitions = [
632
636
  title: { type: "string" },
633
637
  body: { type: "string", description: "Body field HTML" },
634
638
  summary: { type: "string", description: "Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`." },
635
- format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
639
+ format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. allowed_formats is read from field_config, then base_field_override (node body). When that list cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
636
640
  status: { type: "boolean", default: false, description: "Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically." },
637
641
  moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
638
642
  fields: { type: "object", description: "Scalar/attribute field values keyed by Drupal machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`." },
@@ -654,13 +658,13 @@ export const definitions = [
654
658
  title: { type: "string" },
655
659
  body: { type: "string" },
656
660
  summary: { type: "string", description: "Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`." },
657
- format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
661
+ format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before the preview and the write. allowed_formats is read from field_config, then base_field_override (node body). When that list cannot be resolved, an update reuses the format already stored on the field; if the node has none, the site config's `defaultTextFormat`, then 'full_html'. dryRun previews that same format, including on the published-node core PATCH guard." },
658
662
  status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
659
663
  moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'." },
660
664
  langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
661
665
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
662
666
  relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
663
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
667
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
664
668
  returning: RETURNING_SCHEMA,
665
669
  },
666
670
  },