drupal-mcp-connector 2.23.2 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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`.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. Pass revise: true to open an unpublished draft over a language that is already published on the live revision and has no working copy (Sentinel X-MCP-Draft-Mode: revise). That requires MCP Sentinel 2.24.0 or later; an older host is refused before any write and the message names that version. dryRun uses the same translations endpoint and Sentinel's non-saving preflight. A working copy is refused. Paragraph revise is not supported. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers."
2
+ description: "Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. Pass revise: true to open an unpublished draft over a language that is already published on the live revision (Sentinel X-MCP-Draft-Mode: revise). That requires MCP Sentinel 2.24.0 or later; an older host is refused before any write and the message names that version. When the node already has a working copy (for example an English draft), both revision IDs are sent and Sentinel drafts the language on that working copy; the other drafts carry forward. That needs MCP Sentinel 2.25.0 or later. A language that is already a draft on the working copy is continued with drupal_update_node and langcode instead. dryRun uses the same translations endpoint and Sentinel's non-saving preflight. Paragraph revise is not supported. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers."
3
3
  argument-hint: "<type> <id> <langcode> [site] [entityType] [attributes] [relationships] [revisionId] [dryRun] [revise]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_create_translation`.
7
7
 
8
- Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. Pass revise: true to open an unpublished draft over a language that is already published on the live revision and has no working copy (Sentinel X-MCP-Draft-Mode: revise). That requires MCP Sentinel 2.24.0 or later; an older host is refused before any write and the message names that version. dryRun uses the same translations endpoint and Sentinel's non-saving preflight. A working copy is refused. Paragraph revise is not supported. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers.
8
+ Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. Pass revise: true to open an unpublished draft over a language that is already published on the live revision (Sentinel X-MCP-Draft-Mode: revise). That requires MCP Sentinel 2.24.0 or later; an older host is refused before any write and the message names that version. When the node already has a working copy (for example an English draft), both revision IDs are sent and Sentinel drafts the language on that working copy; the other drafts carry forward. That needs MCP Sentinel 2.25.0 or later. A language that is already a draft on the working copy is continued with drupal_update_node and langcode instead. dryRun uses the same translations endpoint and Sentinel's non-saving preflight. Paragraph revise is not supported. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -21,6 +21,6 @@ Parse the arguments supplied with this command into this tool's parameters:
21
21
  - `relationships` (object (pass as JSON)): JSON:API relationships. Use for image alt (same file UUID, meta.alt).
22
22
  - `revisionId` (string): Paragraph revision id the host already pins. Required for Home-shaped non-default pins.
23
23
  - `dryRun` (boolean (true/false)): Validate without saving. Sentinel's non-saving translation preflight receives the real fields, applies them through field access and validates the entity. The result's `checks` block says what was checked. With revise: true the preflight is the same translations endpoint.
24
- - `revise` (boolean (true/false)): Open an unpublished draft over a translation that is already published on the live default revision and has no working copy. Requires MCP Sentinel 2.24.0 or later. Omit it to create a language that does not exist yet.
24
+ - `revise` (boolean (true/false)): Open an unpublished draft over a translation that is already published on the live default revision. Requires MCP Sentinel 2.24.0 or later, and 2.25.0 or later when the node already has a working copy. Omit it to create a language that does not exist yet.
25
25
 
26
26
  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.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. A published translation with no working copy is opened with drupal_create_translation and revise: true, not with this tool."
2
+ description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. A published translation is opened with drupal_create_translation and revise: true, not with this tool."
3
3
  argument-hint: "<type> <id> [site] [name] [status] [fields] [langcode]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_update_media`.
7
7
 
8
- Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. A published translation with no working copy is opened with drupal_create_translation and revise: true, not with this tool.
8
+ Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. A published translation is opened with drupal_create_translation and revise: true, not with this tool.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is already published and has no working copy is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language with no working copy is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran."
2
+ description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is still published (on live, or on the working copy) is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran."
3
3
  argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [langcode] [fields] [relationships] [dryRun] [returning]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_update_node`.
7
7
 
8
- Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is already published and has no working copy is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language with no working copy is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.
8
+ Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is still published (on live, or on the working copy) is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -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,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.24.0] - 2026-09-29
11
+
12
+ ### Added
13
+ - **Paragraphs Library items on the content tiers.** `paragraphs_library_item`
14
+ joins the structural content set, so the `content-editor`, `config-editor`
15
+ and `write-plane` presets can create and edit reusable library items through
16
+ `drupal_entity_*`. Deletes stay blocked. A write carrying
17
+ `moderation_state: published` or `status: true` is refused when
18
+ `allowPublish` is false, as for every other type; drafts pass. On a site
19
+ that moderates library items, Drupal's workflow is the second gate.
20
+
21
+ ### Fixed
22
+ - **A library item created by a tier that cannot publish stays unpublished.**
23
+ Drupal publishes Paragraphs Library items by default. On a site that does
24
+ not moderate them, `drupal_entity_create` or `drupal_bulk_create` without
25
+ `status` put the item live on every page that places it, although
26
+ `allowPublish` was false. When publishing is not allowed and the caller sets
27
+ neither `status` nor `moderation_state`, the connector now sends
28
+ `status: false`. Other entity types are unchanged.
29
+ - **Node update preflight no longer accepts a body format the save rejects (#327).**
30
+ Node body is a base field. Its `allowed_formats` live on
31
+ `base_field_override`, which field lookup did not read, so an omitted format
32
+ fell back to `defaultTextFormat` / `full_html`. On a published moderated node
33
+ with no working copy, `dryRun` only probes core's working-copy guard and does
34
+ not send fields, so the preview succeeded and the save returned 422
35
+ `body.0.format is not a valid choice`. Lookup now reads
36
+ `base_field_override` (JSON:API, then Drush
37
+ `core.base_field_override.*`). When the allowed list is still unknown, an
38
+ update reuses the format already stored on the field. The preview and the
39
+ save resolve that same format, including on the core PATCH-guard path.
40
+
41
+ ## [2.23.3] - 2026-09-28
42
+
43
+ ### Fixed
44
+ - **Revise a published translation on a node that has a working copy.**
45
+ `drupal_create_translation` with `revise: true` used to refuse
46
+ client-side when the node had a working copy (for example an English draft)
47
+ and told the caller to continue it. Continuing cannot draft a language that
48
+ is still published on the working copy, so that language had no path to a
49
+ draft. The tool now sends both revision IDs (`If-Match: "live:working"`),
50
+ and Sentinel drafts the language on that working copy while the other
51
+ drafts carry forward. This needs MCP Sentinel 2.25.0 or later, detected by
52
+ `revise_over_working_copy` in the translation inventory. Older hosts are
53
+ refused before any write, and the message names the version.
54
+ - **Refusals no longer point at each other.** A language that is already a
55
+ draft on the working copy is sent to `drupal_update_node` with `langcode`.
56
+ `drupal_update_node` with `langcode` on a language that is still published
57
+ on the working copy is sent to `drupal_create_translation` with
58
+ `revise: true`.
59
+
10
60
  ## [2.23.2] - 2026-09-28
11
61
 
12
62
  ### 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.2",
3
+ "version": "2.24.0",
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
@@ -29,6 +29,10 @@ export const REVISE_PUBLISHED_TRANSLATION = "revise_published_translation";
29
29
  * Older hosts keep the create 409 and have no path in between.
30
30
  */
31
31
  export const MIN_SENTINEL_REVISE_VERSION = "2.24.0";
32
+ /** Inventory operation: revise accepts a named working copy (`If-Match: "live:working"`). */
33
+ export const REVISE_OVER_WORKING_COPY = "revise_over_working_copy";
34
+ /** First MCP Sentinel release that revises a published translation over a working copy. */
35
+ export const MIN_SENTINEL_REVISE_OVER_WORKING_VERSION = "2.25.0";
32
36
  /** First Sentinel release that keeps a default-language draft out of translation-write rules (#379). */
33
37
  export const MIN_SENTINEL_DEFAULT_LANGUAGE_DRAFT_VERSION = "2.24.2";
34
38
 
@@ -42,6 +46,30 @@ export function supportsRevisePublishedTranslation(meta) {
42
46
  return Array.isArray(meta?.operations) && meta.operations.includes(REVISE_PUBLISHED_TRANSLATION);
43
47
  }
44
48
 
49
+ /**
50
+ * Whether a translation inventory advertises revise over a named working copy.
51
+ * Absent on Sentinel releases before 2.25.0.
52
+ * @param {object} [meta] Inventory `meta`, or an object with `operations`.
53
+ * @returns {boolean}
54
+ */
55
+ export function supportsReviseOverWorkingCopy(meta) {
56
+ return Array.isArray(meta?.operations) && meta.operations.includes(REVISE_OVER_WORKING_COPY);
57
+ }
58
+
59
+ /**
60
+ * Refusal when a working copy exists and the host cannot revise over it.
61
+ * Continuing is not offered: it cannot draft a language that is still
62
+ * published on the working copy.
63
+ * @returns {Error}
64
+ */
65
+ export function reviseOverWorkingCapabilityError() {
66
+ return new Error(
67
+ "This node has a working copy, and this Sentinel host cannot revise a published translation over it " +
68
+ `(missing ${REVISE_OVER_WORKING_COPY}). Update MCP Sentinel ${MIN_SENTINEL_REVISE_OVER_WORKING_VERSION} or later, ` +
69
+ "or publish or discard the working copy first. No write was attempted.",
70
+ );
71
+ }
72
+
45
73
  /**
46
74
  * Refusal when the host cannot revise a published translation.
47
75
  * @returns {Error}
@@ -502,6 +530,12 @@ export function assertInventoryDraftLanguage(inventory, langcode) {
502
530
  throw new Error("This working revision contains translations. Pass an explicit langcode for an existing unpublished language; no draft was created.");
503
531
  }
504
532
  const row = langcode ? rows.find((item) => item.langcode === langcode) : rows[0];
533
+ if (row && row.status === true && langcode && langcode !== inventory.defaultLangcode && row.default !== true) {
534
+ throw new Error(
535
+ "This language is still published on the working copy, so it cannot be continued. " +
536
+ "Open a draft of it with drupal_create_translation and revise: true. Published languages and other drafts were left unchanged.",
537
+ );
538
+ }
505
539
  if (!row || row.status !== false) {
506
540
  throw new Error("The requested language is not an existing unpublished working draft. Published languages and other drafts were left unchanged.");
507
541
  }
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) {
@@ -319,7 +319,7 @@ export const definitions = [
319
319
  },
320
320
  {
321
321
  name: "drupal_update_media",
322
- description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. A published translation with no working copy is opened with drupal_create_translation and revise: true, not with this tool.",
322
+ description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. A published translation is opened with drupal_create_translation and revise: true, not with this tool.",
323
323
  inputSchema: {
324
324
  type: "object", required: ["type", "id"],
325
325
  properties: {
@@ -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`." },
@@ -644,7 +648,7 @@ export const definitions = [
644
648
  },
645
649
  {
646
650
  name: "drupal_update_node",
647
- description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is already published and has no working copy is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language with no working copy is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.",
651
+ description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is still published (on live, or on the working copy) is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.",
648
652
  inputSchema: {
649
653
  type: "object", required: ["type", "id"],
650
654
  properties: {
@@ -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
  },
@@ -37,10 +37,13 @@ import {
37
37
  explainExistingTranslation,
38
38
  isMissingTranslationEndpoint,
39
39
  MIN_SENTINEL_REVISE_VERSION,
40
+ MIN_SENTINEL_REVISE_OVER_WORKING_VERSION,
40
41
  readTranslationInventory,
41
42
  resolveNodeTranslationPair,
42
43
  reviseCapabilityError,
44
+ reviseOverWorkingCapabilityError,
43
45
  supportsRevisePublishedTranslation,
46
+ supportsReviseOverWorkingCopy,
44
47
  supportsSentinelDraft,
45
48
  } from "../lib/sentinel-draft.js";
46
49
  import { mapTranslationRow } from "../lib/translation-rows.js";
@@ -171,12 +174,23 @@ async function createTranslation({
171
174
  if (!supportsRevisePublishedTranslation(draftRevision)) {
172
175
  throw reviseCapabilityError();
173
176
  }
174
- if (draftRevision.workingVid) {
175
- const continuer = entityType === "media" ? "drupal_update_media" : "drupal_update_node";
176
- throw new Error(
177
- "A working copy already exists. Continue that draft with " +
178
- `${continuer} and langcode. Revise was not attempted.`,
179
- );
177
+ const hasWorking = draftRevision.workingVid && draftRevision.liveVid
178
+ && String(draftRevision.workingVid) !== String(draftRevision.liveVid);
179
+ if (hasWorking) {
180
+ const workingRow = (draftRevision.inventory?.working?.translations ?? [])
181
+ .find((row) => row?.langcode === targetLang);
182
+ if (workingRow && workingRow.status === false) {
183
+ const continuer = entityType === "media" ? "drupal_update_media" : "drupal_update_node";
184
+ throw new Error(
185
+ "This language is already an unpublished draft on the working copy. " +
186
+ `Continue it with ${continuer} and langcode. Revise was not attempted.`,
187
+ );
188
+ }
189
+ // Sentinel 2.25.0+ builds the draft on the named working copy; the
190
+ // pair is sent as If-Match "live:working" by createTranslationDraft.
191
+ if (!supportsReviseOverWorkingCopy(draftRevision)) {
192
+ throw reviseOverWorkingCapabilityError();
193
+ }
180
194
  }
181
195
  }
182
196
  if (entityType === "node") {
@@ -275,10 +289,14 @@ export const definitions = [
275
289
  "draft (#282). English live title, body, status, alias, default revision, and " +
276
290
  "paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. " +
277
291
  "Pass revise: true to open an unpublished draft over a language that is already published " +
278
- "on the live revision and has no working copy (Sentinel X-MCP-Draft-Mode: revise). " +
292
+ "on the live revision (Sentinel X-MCP-Draft-Mode: revise). " +
279
293
  `That requires MCP Sentinel ${MIN_SENTINEL_REVISE_VERSION} or later; an older host is refused ` +
280
- "before any write and the message names that version. dryRun uses the same translations " +
281
- "endpoint and Sentinel's non-saving preflight. A working copy is refused. " +
294
+ "before any write and the message names that version. When the node already has a working " +
295
+ "copy (for example an English draft), both revision IDs are sent and Sentinel drafts the " +
296
+ "language on that working copy; the other drafts carry forward. That needs MCP Sentinel " +
297
+ `${MIN_SENTINEL_REVISE_OVER_WORKING_VERSION} or later. A language that is already a draft on the working copy is ` +
298
+ "continued with drupal_update_node and langcode instead. dryRun uses the same translations " +
299
+ "endpoint and Sentinel's non-saving preflight. " +
282
300
  "Paragraph revise is not supported. " +
283
301
  "The response includes `_revisions.live` / `_revisions.working` when known. " +
284
302
  "Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. " +
@@ -300,7 +318,7 @@ export const definitions = [
300
318
  relationships: { type: "object", description: "JSON:API relationships. Use for image alt (same file UUID, meta.alt)." },
301
319
  revisionId: { type: "string", description: "Paragraph revision id the host already pins. Required for Home-shaped non-default pins." },
302
320
  dryRun: { type: "boolean", description: "Validate without saving. Sentinel's non-saving translation preflight receives the real fields, applies them through field access and validates the entity. The result's `checks` block says what was checked. With revise: true the preflight is the same translations endpoint." },
303
- revise: { type: "boolean", description: "Open an unpublished draft over a translation that is already published on the live default revision and has no working copy. Requires MCP Sentinel 2.24.0 or later. Omit it to create a language that does not exist yet." },
321
+ revise: { type: "boolean", description: "Open an unpublished draft over a translation that is already published on the live default revision. Requires MCP Sentinel 2.24.0 or later, and 2.25.0 or later when the node already has a working copy. Omit it to create a language that does not exist yet." },
304
322
  },
305
323
  },
306
324
  },