drupal-mcp-connector 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ ---
2
+ description: "Replace nested paragraphs on a node by creating new unpublished paragraphs and pinning a new direct parent from the node's unpublished draft. children is the full new list: { op: 'keep', id } copies an existing child pin, { op: 'replace', id, type, attributes, translations? } or { op: 'insert', type, attributes, translations? } creates a new child. Omitting a child leaves it off the draft only; it is not deleted. The published host and every paragraph it already pins stay unchanged. Never edits a live child and never sends nested UUIDs as mcp_components (use drupal_update_node components for a direct paragraph's attributes). Reusable library items and from_library children are refused; update the library item with drupal_entity_update. The first host draft requires MCP Sentinel 2.29.0 (open_draft). An existing working copy continues through /mcp-draft. When the host inventory advertises nested_replacement, one /mcp-draft carries meta.mcp_nested_replacement (parent is the parentId input), the connector creates no paragraphs, and it does not also send the host paragraph field. dryRun then uses that non-saving preflight (serverPreflight sentinel_draft). Hosts that do not advertise nested_replacement keep this sequence, including resumeParentId, and dryRun leaves serverPreflight none. New paragraphs are created in the host's default language. The new parent keeps the published parent's other references and replaces only the child field. Non-default translations on the component must be supplied on each replace or insert; they are created on the new paragraphs, and the component's own translations are copied onto the new parent. dryRun checks paragraph-create permission, creates nothing, and does not evaluate the host payload. If paragraph creates succeed and the host pin fails, the error lists the prepared UUIDs. When a parent was prepared, retry with resumeParentId and the same children list; that retry checks the prepared paragraph's fields, other references, and child revision pins before any host write. A failure before a parent exists says to delete the prepared paragraphs and does not offer a host retry. A lost response is uncertain until a re-read proves the prepared revision is pinned; the same UUID at another revision is not success. A second call after success is refused while the forward draft no longer pins the published component. Governed by the site security policy. Nothing is published."
3
+ argument-hint: "<bundle> <id> <field> <parentId> <childField> <children> [site] [liveRevisionId] [resumeParentId] [dryRun]"
4
+ ---
5
+
6
+ Call the MCP tool `drupal_draft_nested_components`.
7
+
8
+ Replace nested paragraphs on a node by creating new unpublished paragraphs and pinning a new direct parent from the node's unpublished draft. children is the full new list: { op: 'keep', id } copies an existing child pin, { op: 'replace', id, type, attributes, translations? } or { op: 'insert', type, attributes, translations? } creates a new child. Omitting a child leaves it off the draft only; it is not deleted. The published host and every paragraph it already pins stay unchanged. Never edits a live child and never sends nested UUIDs as mcp_components (use drupal_update_node components for a direct paragraph's attributes). Reusable library items and from_library children are refused; update the library item with drupal_entity_update. The first host draft requires MCP Sentinel 2.29.0 (open_draft). An existing working copy continues through /mcp-draft. When the host inventory advertises nested_replacement, one /mcp-draft carries meta.mcp_nested_replacement (parent is the parentId input), the connector creates no paragraphs, and it does not also send the host paragraph field. dryRun then uses that non-saving preflight (serverPreflight sentinel_draft). Hosts that do not advertise nested_replacement keep this sequence, including resumeParentId, and dryRun leaves serverPreflight none. New paragraphs are created in the host's default language. The new parent keeps the published parent's other references and replaces only the child field. Non-default translations on the component must be supplied on each replace or insert; they are created on the new paragraphs, and the component's own translations are copied onto the new parent. dryRun checks paragraph-create permission, creates nothing, and does not evaluate the host payload. If paragraph creates succeed and the host pin fails, the error lists the prepared UUIDs. When a parent was prepared, retry with resumeParentId and the same children list; that retry checks the prepared paragraph's fields, other references, and child revision pins before any host write. A failure before a parent exists says to delete the prepared paragraphs and does not offer a host retry. A lost response is uncertain until a re-read proves the prepared revision is pinned; the same UUID at another revision is not success. A second call after success is refused while the forward draft no longer pins the published component. Governed by the site security policy. Nothing is published.
9
+
10
+ Parse the arguments supplied with this command into this tool's parameters:
11
+
12
+ **Required:**
13
+ - `bundle` (string): Node bundle machine name, e.g. 'page'
14
+ - `id` (string): Node UUID
15
+ - `field` (string): Host entity-reference-revisions field that pins the direct parent, e.g. 'field_components'
16
+ - `parentId` (string): UUID of the direct paragraph the published host pins. Not a nested child.
17
+ - `childField` (string): Entity-reference-revisions field on that direct paragraph, e.g. 'field_items'
18
+ - `children` (array (pass as JSON)): Full new child list, in order. keep: { op, id }. replace: { op, id, type, attributes, translations? }. insert: { op, type, attributes, translations? }. translations is [{ langcode, attributes }] for each non-default language the component already has. Relationships inside a child are refused.
19
+
20
+ **Optional:**
21
+ - `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).
22
+ - `liveRevisionId` (string): Optional published revision id. Refused before any create when it is not the current live revision.
23
+ - `resumeParentId` (string): Prepared parent UUID from a previous attempt whose host pin did not finish. Retries only the host pin. Must not be a paragraph the published host already pins.
24
+ - `dryRun` (boolean (true/false)): When nested_replacement is advertised, run that non-saving /mcp-draft preflight and create nothing (checks.serverPreflight is sentinel_draft). Otherwise read the host and component, then return without creating paragraphs or evaluating the host draft payload (checks.serverPreflight is none).
25
+
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 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 a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight 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 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 a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight 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. When the draft inventory advertises open_draft, a published moderated paragraphs_library_item or block_content opens and continues through /mcp-draft with If-Match live or live:working, no mcp_components, and a re-read of the published pins. A continuation compares the forward revision with the pre-write working pins. When open_draft is not advertised, a published moderated paragraphs_library_item or block_content with no forward revision is saved by that canonical draft PATCH, then the published revision, rel:working-copy, and paragraph pins (including order) are re-read. The result fails if the published revision changed or no forward revision appeared. An existing forward draft, a missing revision id, or a denied version read is refused before any write. An unmoderated item keeps the ordinary update. A completed 4xx is returned when that re-read matches the pre-write revision and pins; a lost response stays uncertain (#420)."
3
3
  argument-hint: "<entityType> <bundle> <id> [site] [langcode] [attributes] [relationships] [dryRun] [returning]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_entity_update`.
7
7
 
8
- 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 a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight 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 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 a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight 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. When the draft inventory advertises open_draft, a published moderated paragraphs_library_item or block_content opens and continues through /mcp-draft with If-Match live or live:working, no mcp_components, and a re-read of the published pins. A continuation compares the forward revision with the pre-write working pins. When open_draft is not advertised, a published moderated paragraphs_library_item or block_content with no forward revision is saved by that canonical draft PATCH, then the published revision, rel:working-copy, and paragraph pins (including order) are re-read. The result fails if the published revision changed or no forward revision appeared. An existing forward draft, a missing revision id, or a denied version read is refused before any write. An unmoderated item keeps the ordinary update. A completed 4xx is returned when that re-read matches the pre-write revision and pins; a lost response stays uncertain (#420).
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -19,7 +19,7 @@ Parse the arguments supplied with this command into this tool's parameters:
19
19
  - `langcode` (string): Target language for an unpublished working translation (nodes). Continues that translation via Sentinel.
20
20
  - `attributes` (object (pass as JSON))
21
21
  - `relationships` (object (pass as JSON))
22
- - `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 changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun.
22
+ - `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. An existing node draft (or a langcode translation draft), and a paragraphs_library_item or block_content whose inventory advertises open_draft, are 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 changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun.
23
23
  - `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.
24
24
 
25
25
  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.
@@ -24,7 +24,7 @@ Parse the arguments supplied with this command into this tool's parameters:
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
- - `components` (array (pass as JSON)): Field changes for paragraphs this node already references directly (hero, text blocks, CTA banners), saved in the same unpublished draft revision as the node fields. Each entry is { id: paragraph UUID, type: paragraph bundle such as 'p_hero', attributes: { field: value } }. Each changed paragraph gets a new revision that only the draft points at; the live page does not change until the draft is published. Opens the working copy from live when none exists. Default language only. Attribute values only: references (for example a media field) are refused, as are nested paragraphs and paragraphs the node does not reference. Requires MCP Sentinel 2.29.0 or later; older hosts are refused before any write, with no direct paragraph write. A `path` change is refused on this path. The connector's paragraph write policy applies to each component. After the write each component is read back at its working revision and the submitted values compared; a value that did not land is an error. The result carries `_components` (each component's live and working pins and the fields verified), which survives `returning: 'minimal'`.
27
+ - `components` (array (pass as JSON)): Field changes for paragraphs this node already references directly (hero, text blocks, CTA banners), saved in the same unpublished draft revision as the node fields. Each entry is { id: paragraph UUID, type: paragraph bundle such as 'p_hero', attributes: { field: value } }. Each changed paragraph gets a new revision that only the draft points at; the live page does not change until the draft is published. Opens the working copy from live when none exists. Default language only. Attribute values only: references (for example a media field) are refused, as are paragraphs the node does not reference. A nested paragraph is refused before any write; use drupal_draft_nested_components. Requires MCP Sentinel 2.29.0 or later; older hosts are refused before any write, with no direct paragraph write. A `path` change is refused on this path. The connector's paragraph write policy applies to each component. After the write each component is read back at its working revision and the submitted values compared; a value that did not land is an error. The result carries `_components` (each component's live and working pins and the fields verified), which survives `returning: 'minimal'`.
28
28
  - `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 changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun.
29
29
  - `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.
30
30
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.2.1] - 2026-10-07
11
+
12
+ ### Fixed
13
+ - **Text formats from Sentinel context (#429).** Node writes read
14
+ `allowed_formats` from `GET /drupal-mcp/context` when JSON:API field
15
+ config is not readable. A present list is enforced, including a
16
+ one-element `{ value, format }` array. A missing key stays an unknown
17
+ list, so an older Sentinel still passes an explicit format through.
18
+ Entity, translation, bulk, paragraph, and media writes use the same
19
+ check and do not invent `full_html` for a string body. A JSON:API row
20
+ whose field name does not match the request is ignored. A context miss
21
+ is remembered for 30 seconds, and only tools that resolve text formats
22
+ wait on that document. Component drafts and nested paragraph children,
23
+ including their translations, are checked before preflight or create.
24
+ An update that sends a one-element text array without a format reuses
25
+ the stored format. Hosts stay on the previous behavior until Sentinel
26
+ 2.32.0, which emits the key, is deployed.
27
+
28
+ ### Added
29
+ - **Consumers pin a version and check advisories before upgrade (#423).**
30
+ The README tells operators to pin an exact version, confirm the registry
31
+ integrity checksum, read the changelog, and run `npm audit --omit=dev`
32
+ before upgrading. No separate scanner. The install and `npx` examples,
33
+ and the post-upgrade doctor command, name that same version.
34
+
35
+ ## [3.2.0] - 2026-10-05
36
+
37
+ ### Added
38
+ - **`drupal_draft_nested_components` (#421).** Replaces a nested paragraph
39
+ by creating new unpublished paragraphs and pinning a new direct parent
40
+ from the host's unpublished draft. The published host and the paragraphs
41
+ it already pins are not edited. `children` is the full new list (`keep`,
42
+ `replace`, `insert`; omit a child to leave it off the draft only). The
43
+ first host draft requires MCP Sentinel 2.29.0 (`open_draft`); an existing
44
+ working copy continues through `/mcp-draft`. There is no `mcp_components`
45
+ payload and no direct paragraph write. `dryRun` creates nothing and does
46
+ not evaluate the host payload. A host write that fails after preparation
47
+ reports the new paragraph UUIDs. When a parent was prepared, recovery is
48
+ `resumeParentId` with the same child list; that retry checks the prepared
49
+ paragraph's fields, other references, and child revision pins before any
50
+ host write. A failure before a parent exists says to delete the prepared
51
+ paragraphs. New paragraphs are created in the host's default language, and
52
+ the new parent keeps the published parent's other references. A lost
53
+ response stays uncertain until a re-read proves the prepared revision is
54
+ pinned. Reusable library items are refused; update those with
55
+ `drupal_entity_update`.
56
+
57
+ ### Changed
58
+ - **Advertised library and custom-block drafts (#420, #422).** When the host
59
+ inventory lists `open_draft`, `drupal_entity_update` opens and continues a
60
+ published moderated `paragraphs_library_item` or `block_content` through
61
+ `/mcp-draft`. The body has no `mcp_components`. `If-Match` is the live
62
+ revision, or `live:working` when a forward draft already exists. The
63
+ published revision and its paragraph pins are re-read. A continuation
64
+ compares the forward revision with the pre-write working pins unless
65
+ relationships were submitted. When `open_draft` is not advertised, the
66
+ canonical draft PATCH remains, and an existing forward draft is still
67
+ refused before any write.
68
+ - **Advertised nested replacement (#421, #422).** When the host inventory
69
+ lists `nested_replacement`, `drupal_draft_nested_components` sends one
70
+ `/mcp-draft` with `meta.mcp_nested_replacement`. `parentId` is sent as
71
+ `parent`. The connector creates no paragraphs and does not also send the
72
+ host paragraph field. `dryRun` uses that non-saving preflight. Hosts that
73
+ do not advertise the operation keep the client sequence, including
74
+ `resumeParentId`.
75
+
76
+ ### Fixed
77
+ - **Reusable library and custom-block revision identity (#420).**
78
+ `drupal_internal__revision_id` is kept for `paragraphs_library_item` and
79
+ `block_content`. A published item with no forward revision is updated by a
80
+ canonical draft (`moderation_state: draft` or `review`); the connector then
81
+ re-reads the published revision, `rel:working-copy`, and the published
82
+ paragraph pins, including their order. Success requires a new working
83
+ revision and an unchanged published revision. `rel:latest-version` is the
84
+ latest default revision, so a difference there fails closed instead of
85
+ being treated as the draft. An existing forward draft is refused before
86
+ any write, because these types have no governed continuation endpoint. A
87
+ missing revision id, a denied version read, or a lost response fails
88
+ closed. A lost response is described from a follow-up re-read and is not
89
+ called a rollback. A completed 4xx is returned when that re-read matches
90
+ the published revision and pins; the same 4xx stays uncertain when the
91
+ re-read shows a change or cannot be finished. Drupal's working-copy "No
92
+ pending revision" response is treated as no draft only when its status is
93
+ 403. An unmoderated library item or custom block keeps the ordinary update
94
+ and is not required to answer the version aliases.
95
+
10
96
  ## [3.1.0] - 2026-10-03
11
97
 
12
98
  ### Added
package/README.md CHANGED
@@ -278,8 +278,8 @@ the allowlist or Sentinel profile excludes the type — run `doctor` and
278
278
  ### From npm (recommended for operators)
279
279
 
280
280
  ```bash
281
- npm install -g drupal-mcp-connector
282
- # or: npx -y drupal-mcp-connector
281
+ npm install -g drupal-mcp-connector@3.2.1
282
+ # or: npx -y drupal-mcp-connector@3.2.1
283
283
  ```
284
284
 
285
285
  Point your MCP client at the installed binary (path from `which drupal-mcp-connector`
@@ -287,6 +287,25 @@ or `node_modules/.bin/drupal-mcp-connector`). For multi-site or non-env config,
287
287
  clone the repo (or copy `config/config.example.json` beside a small launcher) and
288
288
  pass a config path — see **[docs/getting-started.md](docs/getting-started.md)**.
289
289
 
290
+ ### Before you upgrade
291
+
292
+ Pin the version you run. Do not point a client you depend on at `latest` or an
293
+ unbounded range.
294
+
295
+ 1. Pin an exact version in the lockfile (`package-lock.json` or
296
+ `npm-shrinkwrap.json`), or in `package.json` as
297
+ `"drupal-mcp-connector": "3.2.1"` (use the version you intend to run).
298
+ 2. Confirm the registry checksum before you install.
299
+ `npm view drupal-mcp-connector@3.2.1 dist.integrity` prints the integrity
300
+ npm checks. A lockfile install fails when the tarball does not match.
301
+ 3. Read [CHANGELOG.md](CHANGELOG.md) for that version.
302
+ 4. Run `npm audit --omit=dev` against the lockfile and read any advisory
303
+ before you upgrade. There is no separate scanner to install.
304
+
305
+ Restart the MCP client after the upgrade. Run `drupal-mcp-doctor` when the
306
+ package is on your PATH, or `npx -y drupal-mcp-connector@3.2.1 doctor` when
307
+ you launch it with npx.
308
+
290
309
  ### From a git clone (development)
291
310
 
292
311
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
4
4
  "description": "Multi-site MCP server for Drupal over JSON:API and GraphQL, with governed writes and an SSH Drush bridge. init writes a stdio or Tailscale client config from a transport preset. doctor checks reachability, auth, and readiness, and reports version skew. A timestamp gap on a published revision no longer blocks the next draft.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/node": "^2.0.0",
75
- "@modelcontextprotocol/server": "^2.0.0",
75
+ "@modelcontextprotocol/server": "^2.2.0",
76
76
  "ajv": "^8.20.0",
77
77
  "ajv-formats": "^3.0.1",
78
78
  "graphql": "^17.0.0",
@@ -81,12 +81,12 @@
81
81
  "ssh2": "^1.16.0"
82
82
  },
83
83
  "devDependencies": {
84
- "@modelcontextprotocol/client": "^2.0.0",
84
+ "@modelcontextprotocol/client": "^2.2.0",
85
85
  "eslint": "^10.4.1",
86
- "eslint-plugin-n": "^18.1.0",
87
- "eslint-plugin-security": "^4.0.0",
88
- "globals": "^17.6.0",
89
- "vitest": "^5.0.0"
86
+ "eslint-plugin-n": "^18.4.0",
87
+ "eslint-plugin-security": "^4.2.0",
88
+ "globals": "^17.13.0",
89
+ "vitest": "^5.0.3"
90
90
  },
91
91
  "overrides": {
92
92
  "@hono/node-server": "^2.0.5",
package/src/index.js CHANGED
@@ -50,6 +50,7 @@ import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-
50
50
  import { createConnectorServerFactory } from "./lib/mcp-server.js";
51
51
  import { createRateLimiter } from "./lib/rate-limit.js";
52
52
  import { callTool, invokeReadTool, listResolvableSiteConfigs } from "./lib/dispatch.js";
53
+ import { fetchTextFormatContext, installTextFormatContextFetcher } from "./lib/text-format-context.js";
53
54
  import { filterDiscoverableTools } from "./lib/governance.js";
54
55
  import {
55
56
  filterPromptsByPrincipal,
@@ -75,6 +76,8 @@ import { builtinWorkflowProvider } from "./lib/workflows/builtin.js";
75
76
  import { runWizardCli } from "./lib/wizard.js";
76
77
  import { runDoctorCli } from "./lib/doctor.js";
77
78
 
79
+ installTextFormatContextFetcher(fetchTextFormatContext);
80
+
78
81
  const cliCommand = process.argv[2];
79
82
  if (cliCommand === "wizard" || cliCommand === "init") {
80
83
  process.exit(await runWizardCli(process.argv.slice(3)));
@@ -349,12 +349,15 @@ export class JsonApiBackend extends Backend {
349
349
  // Node reads expose the numeric nid advertised by schema discovery
350
350
  // while the UUID remains the canonical top-level id (#237).
351
351
  // Paragraph ERR attach needs the current revision id (#192).
352
+ // Library items and custom blocks use revision_id, not vid (#420).
352
353
  // Node / revisionable writes need the working vs live vid (#166).
353
354
  // Menu-link admin edit URLs need the numeric id (#398).
354
355
  // Other drupal_internal__* attributes stay stripped.
355
356
  return (entityType === "node" && k === "drupal_internal__nid")
356
357
  || k === "drupal_internal__vid"
357
- || (entityType === "paragraph" && k === "drupal_internal__revision_id")
358
+ || ((entityType === "paragraph"
359
+ || entityType === "paragraphs_library_item"
360
+ || entityType === "block_content") && k === "drupal_internal__revision_id")
358
361
  || (entityType === "menu_link_content" && k === "drupal_internal__id");
359
362
  }
360
363
  return true;
@@ -594,18 +597,28 @@ export class JsonApiBackend extends Backend {
594
597
  /**
595
598
  * Create an entity via JSON:API POST. Retries without `status` on moderated
596
599
  * bundles — see writeWithModerationFallback.
597
- * @param {{entityType: string, bundle: string, attributes?: object, relationships?: object}} input
600
+ * @param {{entityType: string, bundle: string, attributes?: object, relationships?: object, langcode?: string}} input
601
+ * `langcode` creates the entity in that language (URL prefix, then language headers).
598
602
  * @returns {Promise<import("../canonical.js").CanonicalEntity>} The created entity.
599
603
  */
600
- async createEntity({ entityType, bundle, attributes = {}, relationships }) {
604
+ async createEntity({ entityType, bundle, attributes = {}, relationships, langcode }) {
601
605
  const buildPayload = (attrs) => {
602
606
  const payload = { data: { type: `${entityType}--${bundle}`, attributes: attrs } };
603
607
  const rels = grantActorUid(entityType, relationships);
604
608
  if (rels) payload.data.relationships = rels;
605
609
  return payload;
606
610
  };
607
- const data = await this.writeWithModerationFallback(this.resourcePath(entityType, bundle), "POST", buildPayload, attributes);
608
- return this.toCanonical(data.data);
611
+ const data = await this.writeWithModerationFallback(
612
+ this.resourcePath(entityType, bundle), "POST", buildPayload, attributes, langcode,
613
+ );
614
+ const entity = this.toCanonical(data.data);
615
+ try {
616
+ assertServedLanguage(entity, langcode);
617
+ } catch (err) {
618
+ if (err && typeof err === "object") err.entity = entity;
619
+ throw err;
620
+ }
621
+ return entity;
609
622
  }
610
623
 
611
624
  /**
@@ -871,7 +884,10 @@ export class JsonApiBackend extends Backend {
871
884
  try {
872
885
  const data = await drupalFetch(this.site, `/jsonapi/${resource}/${resource}?${params}`);
873
886
  const row = Array.isArray(data?.data) ? data.data[0] : data?.data;
874
- return parseFieldConfigObject(row?.attributes, fieldName);
887
+ // A filter that is ignored would return some other field. Using that
888
+ // row's allowed_formats would persist the wrong format (#429).
889
+ if (row?.attributes?.field_name !== fieldName) return null;
890
+ return parseFieldConfigObject(row.attributes, fieldName);
875
891
  } catch {
876
892
  return null;
877
893
  }