drupal-mcp-connector 2.7.0 → 2.7.2

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.
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Writes default to unpublished/draft."
2
+ description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Paragraph items also return relationshipData with meta.target_revision_id for a later host attach. Writes default to unpublished/draft."
3
3
  argument-hint: "<entityType> <bundle> <items> [site]"
4
4
  allowed-tools: mcp__drupal__drupal_bulk_create
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_bulk_create` MCP tool.
8
8
 
9
- Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Writes default to unpublished/draft.
9
+ Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Paragraph items also return relationshipData with meta.target_revision_id for a later host attach. Writes default to unpublished/draft.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id: <uuid> }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy."
2
+ description: "Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Drupal ERR items are empty without that meta key — do not send {type, id} alone. Before creating paragraphs to attach to a published moderated node, call drupal_list_revisions (possiblyPatchBlocked) and dryRun the host update so a doomed PATCH does not orphan them. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy."
3
3
  argument-hint: "<paragraphType> [site] [attributes]"
4
4
  allowed-tools: mcp__drupal__drupal_create_paragraph
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_create_paragraph` MCP tool.
8
8
 
9
- Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id: <uuid> }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.
9
+ Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Drupal ERR items are empty without that meta key — do not send {type, id} alone. Before creating paragraphs to attach to a published moderated node, call drupal_list_revisions (possiblyPatchBlocked) and dryRun the host update so a doomed PATCH does not orphan them. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision)."
2
+ description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets an id-mismatch PATCH preflight runs first (including dryRun) so a core working-copy guard failure is reported before the real write and no revision is saved by the probe (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents."
3
3
  argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun] [returning]"
4
4
  allowed-tools: mcp__drupal__drupal_entity_update
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_entity_update` MCP tool.
8
8
 
9
- Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision).
9
+ Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets an id-mismatch PATCH preflight runs first (including dryRun) so a core working-copy guard failure is reported before the real write and no revision is saved by the probe (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -19,7 +19,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
19
19
  - `site` (string): omit for the default site
20
20
  - `attributes` (object (pass as JSON))
21
21
  - `relationships` (object (pass as JSON))
22
- - `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
22
+ - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun.
23
23
  - `returning` (string): 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.
24
24
 
25
25
  If a required parameter is missing from `$ARGUMENTS`, 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,12 +1,12 @@
1
1
  ---
2
- description: "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph plus a `ref` ({ type: 'paragraph--<bundle>', id }) you can use to embed it in a host entity's paragraph / ERR field. Note: paragraphs are referenced (by target_id + target_revision_id in the entity API, or by UUID over JSON:API) from a host field rather than queried standalone in production. Governed by the site security policy."
2
+ description: "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy."
3
3
  argument-hint: "<paragraphType> <id> [site]"
4
4
  allowed-tools: mcp__drupal__drupal_get_paragraph
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_get_paragraph` MCP tool.
8
8
 
9
- Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph plus a `ref` ({ type: 'paragraph--<bundle>', id }) you can use to embed it in a host entity's paragraph / ERR field. Note: paragraphs are referenced (by target_id + target_revision_id in the entity API, or by UUID over JSON:API) from a host field rather than queried standalone in production. Governed by the site security policy.
9
+ Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Report each configured site's source-governance condition: whether governance is required, whether the source contract verifies, and the failed condition when it does not. Callable even while governed paths are denied — this is the diagnostic for that denial."
2
+ description: "Report each configured site's source-governance condition. Always probes GET /drupal-mcp/readiness (even when this client does not require governance) and surfaces the server's reason verbatim. Never reports ok:true unless that check ran. Callable even while governed paths are denied — this is the diagnostic for that denial."
3
3
  argument-hint: "[site]"
4
4
  allowed-tools: mcp__drupal__drupal_governance_status
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_governance_status` MCP tool.
8
8
 
9
- Report each configured site's source-governance condition: whether governance is required, whether the source contract verifies, and the failed condition when it does not. Callable even while governed paths are denied — this is the diagnostic for that denial.
9
+ Report each configured site's source-governance condition. Always probes GET /drupal-mcp/readiness (even when this client does not require governance) and surfaces the server's reason verbatim. Never reports ok:true unless that check ran. Callable even while governed paths are denied — this is the diagnostic for that denial.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. NOTE: JSON:API cannot enumerate full chronological revision history it only addresses revisions by id or the latest/working-copy aliases. Full history enumeration requires the Drush bridge. Use drupal_report_revision_hotspots for per-node revision counts."
2
+ description: "Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. workingCopy: null is not an all-clear Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge."
3
3
  argument-hint: "<type> <id> [site]"
4
4
  allowed-tools: mcp__drupal__drupal_list_revisions
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_list_revisions` MCP tool.
8
8
 
9
- Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. NOTE: JSON:API cannot enumerate full chronological revision history it only addresses revisions by id or the latest/working-copy aliases. Full history enumeration requires the Drush bridge. Use drupal_report_revision_hotspots for per-node revision counts.
9
+ Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. workingCopy: null is not an all-clear Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. Flags 'approximate' when sampling-bounded."
2
+ description: "Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. A 404 (or unaddressable ref) is an orphan; 401/403 and connector policy denials are counted as unverifiable, not missing. uid/revision_uid are skipped when the policy denies user. Flags 'approximate' when sampling-bounded."
3
3
  argument-hint: "[site] [type] [sampleSize]"
4
4
  allowed-tools: mcp__drupal__drupal_report_orphaned_references
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_report_orphaned_references` MCP tool.
8
8
 
9
- Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. Flags 'approximate' when sampling-bounded.
9
+ Find entities whose entity-reference fields point at targets that no longer exist (orphaned references). Best-effort: samples entities and probes each distinct referenced target via JSON:API. A 404 (or unaddressable ref) is an orphan; 401/403 and connector policy denials are counted as unverifiable, not missing. uid/revision_uid are skipped when the policy denies user. Flags 'approximate' when sampling-bounded.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
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. Entity-reference fields go in `relationships`, not `fields`."
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. 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 an id-mismatch PATCH preflight runs first — including on dryRun — so a core working-copy guard failure is reported before the real write and no revision is saved by the probe. 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."
3
3
  argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
4
4
  allowed-tools: mcp__drupal__drupal_update_node
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_update_node` MCP tool.
8
8
 
9
- 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. Entity-reference fields go in `relationships`, not `fields`.
9
+ 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. 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 an id-mismatch PATCH preflight runs first — including on dryRun — so a core working-copy guard failure is reported before the real write and no revision is saved by the probe. 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.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -23,8 +23,8 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
23
23
  - `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
24
24
  - `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'.
25
25
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here.
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 }, …] }.
27
- - `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
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.
27
+ - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun.
28
28
  - `returning` (string): 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 from `$ARGUMENTS`, 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,12 +1,12 @@
1
1
  ---
2
- description: "Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to this paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy."
2
+ description: "Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy."
3
3
  argument-hint: "<paragraphType> <id> [site] [attributes]"
4
4
  allowed-tools: mcp__drupal__drupal_update_paragraph
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_update_paragraph` MCP tool.
8
8
 
9
- Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to this paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.
9
+ Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.7.2] - 2026-08-18
11
+
12
+ ### Fixed
13
+ - **Orphan-reference report no longer treats 403 / policy denial as a missing
14
+ target (#205).** `drupal_report_orphaned_references` used to count any
15
+ non-OK probe as an orphan. On a site whose policy denies `user`, every
16
+ `uid` / `revision_uid` became a false finding — 64 "orphans" across 32
17
+ healthy nodes in the report that produced this issue. Only a 404 (or an
18
+ unaddressable ref) is an orphan. Denied targets are a third state:
19
+ `unverifiable` plus `reason: "target entity type denied by policy"`.
20
+ Author base fields are skipped when the policy denies `user`, so the
21
+ auditor presets stop manufacturing corruption.
22
+ - **PATCH-blocked message names a pending draft when one is visible
23
+ (#201 follow-up).** The preflight added in 2.7.1 always said the blocking
24
+ row was invisible and needed revision surgery. That is right for a stray
25
+ revision with no content_moderation working copy, and wrong for an
26
+ ordinary open draft — the common case, and the dangerous advice. The
27
+ preflight now loads `rel:working-copy`: if it resolves, the error is
28
+ "This node has a pending draft (vid N). Publish or discard it before a
29
+ canonical PATCH." Surgery is mentioned only when the working copy does
30
+ not resolve and the guard still fires.
31
+ - **`drupal_governance_status` no longer reports `ok: true` without a
32
+ check (#208).** When the client did not set `requireGovernance`, the
33
+ diagnostic skipped the readiness probe and returned
34
+ `{ required: false, ok: true, checkedAt: null }` while the same site
35
+ 503'd every governed request with `designated_consumer_disabled`. It
36
+ now always probes `GET /drupal-mcp/readiness`, sets `checked: true` and
37
+ `checkedAt`, and surfaces the server's reason verbatim. `ok: true` only
38
+ after that check. Unresolved site configs report `checked: false` and
39
+ omit `checkedAt`.
40
+
41
+ ## [2.7.1] - 2026-08-18
42
+
43
+ ### Fixed
44
+ - **Paragraph ERR attach sends `meta.target_revision_id` (#192).** JSON:API
45
+ only persists an Entity Reference Revisions item when the resource
46
+ identifier carries the current revision id. The connector used to send
47
+ `{ type, id }` and document that Drupal would fill in the vid — Drupal
48
+ does not, and the field is saved empty. Host writes
49
+ (`drupal_update_node`, `drupal_entity_update`, bulk update) now resolve
50
+ each paragraph identifier (preferring a vid from the create response)
51
+ and fail the whole write if any ref cannot be resolved. Create/update/get
52
+ paragraph tools return `relationshipData` / `ref` with that meta key, and
53
+ paragraph reads surface `drupal_internal__revision_id`. An empty array is
54
+ still an explicit clear.
55
+ - **Write responses no longer treat the canonical re-read as proof a
56
+ relationship landed (#169).** When relationships were sent, the returned
57
+ body is the `rel:working-copy` revision when addressable, otherwise the
58
+ PATCH body plus `_revision.relationshipsUnverified`.
59
+ - **PATCH preflight for the core working-copy guard (#201).**
60
+ `drupal_list_revisions` reports `possiblyPatchBlocked` when the default
61
+ revision's `changed` is later than its `revision_timestamp`, and never
62
+ treats `workingCopy: null` as an all-clear. `drupal_update_node` /
63
+ `drupal_entity_update` (and their `dryRun`) probe the same canonical
64
+ URL before the real write. The probe PATCH uses a non-matching `data.id`
65
+ so core's working-copy guard still runs and `$entity->save()` does not
66
+ (an empty-body 2xx would have written a revision). A core working-copy
67
+ 400 is rewritten to say the stored entity is not the latest revision,
68
+ the JSON:API aliases cannot show the blocking row, and clearing it is
69
+ revision surgery outside JSON:API (Drush / the entity API). Preflight
70
+ on the host write does not un-orphan paragraphs already created — probe
71
+ the host (`possiblyPatchBlocked`, then `dryRun`) *before*
72
+ `drupal_create_paragraph`.
73
+
10
74
  ## [2.7.0] - 2026-08-17
11
75
 
12
76
  ### Security
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
- **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.7.0** (or at least 2.6.1), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
12
+ **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.7.2** (or at least 2.6.1), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
13
13
 
14
14
  ---
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.7.0",
3
+ "version": "2.7.2",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -66,7 +66,10 @@ export class Backend {
66
66
 
67
67
  /**
68
68
  * Fetch a single entity by reference.
69
- * @param {{entityType: string, bundle: string, id: string}} _ref
69
+ * @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} _ref
70
+ * `resourceVersion` is a JSON:API revision selector (`rel:working-copy`,
71
+ * `rel:latest-version`, `id:<vid>`). Backends that cannot address revisions
72
+ * ignore it.
70
73
  * @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null when absent.
71
74
  */
72
75
  async getEntity(_ref) { return notImplemented("getEntity"); }
@@ -204,9 +204,15 @@ export class JsonApiBackend extends Backend {
204
204
  const bundle = rawBundle || null;
205
205
  const attrs = resource.attributes || {};
206
206
  const fields = Object.fromEntries(
207
- Object.entries(attrs).filter(
208
- ([k]) => !BASE_ATTRIBUTE_FIELDS.includes(k) && !INTERNAL_ATTR_RE.test(k)
209
- )
207
+ Object.entries(attrs).filter(([k]) => {
208
+ if (BASE_ATTRIBUTE_FIELDS.includes(k)) return false;
209
+ if (INTERNAL_ATTR_RE.test(k)) {
210
+ // Paragraph ERR attach needs the current revision id (#192).
211
+ // Other drupal_internal__* attributes stay stripped.
212
+ return entityType === "paragraph" && k === "drupal_internal__revision_id";
213
+ }
214
+ return true;
215
+ })
210
216
  );
211
217
  const relationships = Object.fromEntries(
212
218
  Object.entries(resource.relationships || {}).map(([k, rel]) => [k, normalizeRelationship(rel?.data ?? null)])
@@ -249,12 +255,16 @@ export class JsonApiBackend extends Backend {
249
255
 
250
256
  /**
251
257
  * Fetch a single entity by id.
252
- * @param {{entityType: string, bundle: string, id: string}} ref
258
+ * @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} ref
253
259
  * @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null.
254
260
  */
255
- async getEntity({ entityType, bundle, id }) {
261
+ async getEntity({ entityType, bundle, id, resourceVersion }) {
256
262
  validateUuid(id);
257
- const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`);
263
+ let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
264
+ if (resourceVersion) {
265
+ path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
266
+ }
267
+ const data = await drupalFetch(this.site, path);
258
268
  return data?.data ? this.toCanonical(data.data) : null;
259
269
  }
260
270
 
@@ -64,7 +64,17 @@ export function normalizeRelationship(ref) {
64
64
  if (Array.isArray(ref)) return ref.map(normalizeRelationship);
65
65
  // JSON:API encodes type as "entityType--bundle"; split into the two parts.
66
66
  const [entityType = null, bundle = null] = (ref.type || "").split("--");
67
- return { id: ref.id, entityType, bundle };
67
+ const out = { id: ref.id, entityType, bundle };
68
+ // ERR identifiers carry the revision id in JSON:API `meta` (#192). Dropping
69
+ // it made every canonical re-read look like a plain {id, type} even when the
70
+ // write sent target_revision_id.
71
+ if (ref.meta && typeof ref.meta === "object") {
72
+ const vid = new Map(Object.entries(ref.meta)).get("target_revision_id");
73
+ if (vid !== undefined && vid !== null && vid !== "") {
74
+ out.meta = { target_revision_id: vid };
75
+ }
76
+ }
77
+ return out;
68
78
  }
69
79
 
70
80
  /**
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Resolve Entity Reference Revisions identifiers before a host write (#192).
3
+ *
4
+ * Drupal's ERR item is empty unless both `target_id` and `target_revision_id`
5
+ * are set. JSON:API only receives the revision id when the resource identifier
6
+ * carries `meta.target_revision_id`. Sending `{ type, id }` persists an empty
7
+ * field — not a no-op — so a draft forked from a published node with N refs
8
+ * lands with 0.
9
+ *
10
+ * Ordinary entity-reference fields (taxonomy, media, nodes, users) must not
11
+ * require a revision id. Heuristic: resource types starting with `paragraph--`
12
+ * are ERR targets and must resolve; anything else is left unchanged. An empty
13
+ * `data` array is an explicit clear and is sent as-is.
14
+ */
15
+
16
+ /**
17
+ * Thrown when one or more paragraph identifiers cannot be given a
18
+ * `target_revision_id`. The host write must not proceed.
19
+ */
20
+ export class ErrRelationshipError extends Error {
21
+ /**
22
+ * @param {string} message Human-readable reason.
23
+ * @param {{unresolved?: Array<{id: ?string, reason: string}>}} [details]
24
+ */
25
+ constructor(message, details = {}) {
26
+ super(message);
27
+ this.name = "ErrRelationshipError";
28
+ this.details = details;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Whether a JSON:API resource type is a paragraph bundle.
34
+ * @param {*} type Resource type string, e.g. "paragraph--capability".
35
+ * @returns {boolean}
36
+ */
37
+ export function isParagraphResourceType(type) {
38
+ return typeof type === "string" && type.startsWith("paragraph--");
39
+ }
40
+
41
+ /**
42
+ * Split a JSON:API resource type into entity type + bundle.
43
+ * @param {string} type e.g. "paragraph--capability".
44
+ * @returns {?{entityType: string, bundle: string}}
45
+ */
46
+ export function parseResourceType(type) {
47
+ if (typeof type !== "string" || !type.includes("--")) return null;
48
+ const [entityType, ...rest] = type.split("--");
49
+ return { entityType, bundle: rest.join("--") };
50
+ }
51
+
52
+ /**
53
+ * Prefer the vid on a just-created/updated paragraph (before a follow-up GET).
54
+ * Fall back to an un-redacted GET when the write result did not carry it.
55
+ * @param {object} backend Backend with `getEntity`.
56
+ * @param {?object} paragraph Create/update result.
57
+ * @param {string} bundle Paragraph type machine name.
58
+ * @returns {Promise<?number>}
59
+ */
60
+ export async function resolveParagraphRevisionId(backend, paragraph, bundle) {
61
+ const fromWrite = paragraphRevisionId(paragraph);
62
+ if (fromWrite !== null) return fromWrite;
63
+ if (!paragraph?.id || typeof backend?.getEntity !== "function") return null;
64
+ const fresh = await backend.getEntity({
65
+ entityType: "paragraph",
66
+ bundle: paragraph.bundle || bundle,
67
+ id: paragraph.id,
68
+ }).catch(() => null);
69
+ return paragraphRevisionId(fresh);
70
+ }
71
+
72
+ /**
73
+ * Error when a paragraph write succeeded but no revision id is readable.
74
+ * Returning `{type, id}` would persist an empty ERR field (#192).
75
+ * @param {string} id Paragraph UUID.
76
+ * @param {"Created"|"Updated"} [operation="Created"] The write that already landed.
77
+ * @returns {Error}
78
+ */
79
+ export function missingParagraphRevisionError(id, operation = "Created") {
80
+ const verb = operation === "Updated" ? "Updated" : "Created";
81
+ return new Error(
82
+ `${verb} paragraph ${id} but could not read drupal_internal__revision_id; ` +
83
+ "refusing to return a relationship identifier Drupal would persist as empty (#192)."
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Read `drupal_internal__revision_id` from a canonical entity (or a raw-ish
89
+ * object that still carries the attribute at the top level).
90
+ * @param {?object} entity
91
+ * @returns {?number}
92
+ */
93
+ export function paragraphRevisionId(entity) {
94
+ if (!entity || typeof entity !== "object") return null;
95
+ const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : {};
96
+ const raw = Object.prototype.hasOwnProperty.call(fields, "drupal_internal__revision_id")
97
+ ? fields.drupal_internal__revision_id
98
+ : entity.drupal_internal__revision_id;
99
+ if (raw === undefined || raw === null || raw === "") return null;
100
+ const n = Number(raw);
101
+ return Number.isFinite(n) ? n : null;
102
+ }
103
+
104
+ /**
105
+ * Build the JSON:API resource identifier used to embed a paragraph in a host
106
+ * ERR field. Includes `meta.target_revision_id` when a vid is known.
107
+ * @param {string} bundle Paragraph type machine name.
108
+ * @param {string} id Paragraph UUID.
109
+ * @param {number|string|null|undefined} revisionId Current revision id.
110
+ * @returns {{type: string, id: string, meta?: {target_revision_id: number}}}
111
+ */
112
+ export function embedParagraphRef(bundle, id, revisionId) {
113
+ const ref = { type: `paragraph--${bundle}`, id };
114
+ if (revisionId === undefined || revisionId === null || revisionId === "") return ref;
115
+ const n = Number(revisionId);
116
+ if (!Number.isFinite(n)) return ref;
117
+ ref.meta = { target_revision_id: n };
118
+ return ref;
119
+ }
120
+
121
+ /**
122
+ * Whether a resource identifier already carries a usable target revision id.
123
+ * @param {?object} item
124
+ * @returns {boolean}
125
+ */
126
+ export function linkageHasRevisionMeta(item) {
127
+ const vid = item?.meta?.target_revision_id;
128
+ return vid !== undefined && vid !== null && vid !== "";
129
+ }
130
+
131
+ /**
132
+ * Whether the caller supplied any relationship fields (including an explicit
133
+ * empty-array clear). An omitted / empty object is "not sent".
134
+ * @param {?object} relationships
135
+ * @returns {boolean}
136
+ */
137
+ export function relationshipsWereSent(relationships) {
138
+ if (!relationships || typeof relationships !== "object" || Array.isArray(relationships)) {
139
+ return false;
140
+ }
141
+ return Object.keys(relationships).length > 0;
142
+ }
143
+
144
+ /**
145
+ * Clone a resource identifier, preserving `meta` when present.
146
+ * @param {object} item
147
+ * @returns {{type: *, id: *, meta?: object}}
148
+ */
149
+ function cloneIdentifier(item) {
150
+ const out = { type: item.type, id: item.id };
151
+ if (item.meta && typeof item.meta === "object") out.meta = { ...item.meta };
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Resolve one resource identifier. Paragraph refs without a vid are loaded
157
+ * un-redacted and stamped with `meta.target_revision_id`. Failures are
158
+ * recorded on `unresolved` rather than thrown so a mixed list is never
159
+ * half-applied.
160
+ * @param {object} backend Backend with `getEntity`.
161
+ * @param {object} item Resource identifier.
162
+ * @param {Map<string, number>} cache uuid → revision id (create-response vids).
163
+ * @param {Array<{id: ?string, reason: string}>} unresolved
164
+ * @returns {Promise<object>}
165
+ */
166
+ async function resolveOneIdentifier(backend, item, cache, unresolved) {
167
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
168
+ unresolved.push({ id: null, reason: "malformed resource identifier" });
169
+ return item;
170
+ }
171
+ if (typeof item.type !== "string" || typeof item.id !== "string") {
172
+ unresolved.push({ id: item.id ?? null, reason: "resource identifier must include type and id" });
173
+ return item;
174
+ }
175
+
176
+ const out = cloneIdentifier(item);
177
+
178
+ // Ordinary entity-reference (taxonomy, media, node, user, file): leave alone.
179
+ if (!isParagraphResourceType(item.type)) return out;
180
+
181
+ if (linkageHasRevisionMeta(out)) {
182
+ const n = Number(out.meta.target_revision_id);
183
+ if (Number.isFinite(n)) {
184
+ out.meta = { ...out.meta, target_revision_id: n };
185
+ cache.set(item.id, n);
186
+ return out;
187
+ }
188
+ }
189
+
190
+ if (cache.has(item.id)) {
191
+ out.meta = { ...(out.meta || {}), target_revision_id: cache.get(item.id) };
192
+ return out;
193
+ }
194
+
195
+ const parsed = parseResourceType(item.type);
196
+ if (!parsed) {
197
+ unresolved.push({ id: item.id, reason: `unparseable type "${item.type}"` });
198
+ return out;
199
+ }
200
+
201
+ let entity = null;
202
+ try {
203
+ entity = await backend.getEntity({
204
+ entityType: parsed.entityType,
205
+ bundle: parsed.bundle,
206
+ id: item.id,
207
+ });
208
+ } catch (err) {
209
+ const reason = err instanceof Error ? err.message : String(err);
210
+ unresolved.push({ id: item.id, reason: `GET failed: ${reason}` });
211
+ return out;
212
+ }
213
+ if (!entity) {
214
+ unresolved.push({ id: item.id, reason: "GET returned 404 / null" });
215
+ return out;
216
+ }
217
+
218
+ const vid = paragraphRevisionId(entity);
219
+ if (vid === null) {
220
+ // Paragraphs are revisionable. A missing vid is a connector/backend gap,
221
+ // not "not revisionable" — sending {type,id} would persist empty.
222
+ unresolved.push({ id: item.id, reason: "paragraph has no drupal_internal__revision_id" });
223
+ return out;
224
+ }
225
+ cache.set(item.id, vid);
226
+ out.meta = { ...(out.meta || {}), target_revision_id: vid };
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Inject `meta.target_revision_id` on every paragraph identifier in a
232
+ * JSON:API relationships map. Throws before the caller PATCHes if any
233
+ * paragraph ref cannot be resolved. Empty arrays and `data: null` pass
234
+ * through (explicit clear). Non-paragraph refs are unchanged.
235
+ *
236
+ * @param {object} backend Backend with un-redacted `getEntity`.
237
+ * @param {?object} relationships JSON:API relationships map.
238
+ * @param {{revisionCache?: Map<string, number>}} [options]
239
+ * @returns {Promise<?object>} A new relationships map, or the input when empty.
240
+ * @throws {ErrRelationshipError} If any paragraph identifier cannot be resolved.
241
+ */
242
+ export async function resolveErrRelationships(backend, relationships, options = {}) {
243
+ if (relationships === null || relationships === undefined) return relationships;
244
+ if (typeof relationships !== "object" || Array.isArray(relationships)) return relationships;
245
+
246
+ const cache = options.revisionCache instanceof Map ? options.revisionCache : new Map();
247
+ const unresolved = [];
248
+ const entries = [];
249
+
250
+ for (const [field, rel] of Object.entries(relationships)) {
251
+ if (!rel || typeof rel !== "object" || !Object.prototype.hasOwnProperty.call(rel, "data")) {
252
+ entries.push([field, rel]);
253
+ continue;
254
+ }
255
+ const { data } = rel;
256
+ if (data === null) {
257
+ entries.push([field, { ...rel, data: null }]);
258
+ continue;
259
+ }
260
+ if (Array.isArray(data)) {
261
+ // Empty array is an explicit clear — do not resolve, do not fail.
262
+ if (data.length === 0) {
263
+ entries.push([field, { ...rel, data: [] }]);
264
+ continue;
265
+ }
266
+ const items = [];
267
+ for (const item of data) {
268
+ items.push(await resolveOneIdentifier(backend, item, cache, unresolved));
269
+ }
270
+ entries.push([field, { ...rel, data: items }]);
271
+ continue;
272
+ }
273
+ entries.push([field, { ...rel, data: await resolveOneIdentifier(backend, data, cache, unresolved) }]);
274
+ }
275
+
276
+ if (unresolved.length) {
277
+ const listed = unresolved.map((u) => `${u.id ?? "(missing id)"}: ${u.reason}`).join("; ");
278
+ throw new ErrRelationshipError(
279
+ "Cannot attach paragraph relationship: failed to resolve target_revision_id " +
280
+ `for ${unresolved.length} identifier(s). The write was not sent — an unresolved ` +
281
+ `ERR identifier would persist an empty field. ${listed}`,
282
+ { unresolved }
283
+ );
284
+ }
285
+ return Object.fromEntries(entries);
286
+ }