drupal-mcp-connector 2.15.0 → 2.15.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,11 +1,11 @@
1
1
  ---
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. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced 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. Pass revisionId (the host pin) to read that revision via JSON:API resourceVersion=id:<vid>; a mismatch or miss is an error, not a silent default-revision fallback. Pass langcode to read an unpublished working translation via Sentinel. 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] [langcode] [revisionId]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_get_paragraph`.
7
7
 
8
- 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. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.
8
+ 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. Pass revisionId (the host pin) to read that revision via JSON:API resourceVersion=id:<vid>; a mismatch or miss is an error, not a silent default-revision fallback. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
@@ -16,6 +16,6 @@ Parse the arguments supplied with this command into this tool's parameters:
16
16
  **Optional:**
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
  - `langcode` (string): Target language for the unpublished working translation (e.g. 'es').
19
- - `revisionId` (string): Paragraph revision id the host already pins.
19
+ - `revisionId` (string): Paragraph revision id the host already pins (ERR meta.target_revision_id). Honored on the read; a different served vid is an error.
20
20
 
21
21
  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: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors."
2
+ description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors. Drupal core JSON:API caps page[limit] at 50; a larger requested limit is filled by following links.next (up to 1000). When the site does not expose meta.count, total is exact only if this window reached the end of the collection; otherwise approximate is true and hasNext is set."
3
3
  argument-hint: "<type> [site] [status] [limit] [offset] [filters] [sort]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_list_nodes`.
7
7
 
8
- List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors.
8
+ List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors. Drupal core JSON:API caps page[limit] at 50; a larger requested limit is filled by following links.next (up to 1000). When the site does not expose meta.count, total is exact only if this window reached the end of the collection; otherwise approximate is true and hasNext is set.
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: "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."
2
+ description: "Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. 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
  ---
5
5
 
6
6
  Call the MCP tool `drupal_list_revisions`.
7
7
 
8
- 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.
8
+ Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. 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.
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. 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. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that 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."
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. 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. Published languages are not converted into drafts; 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."
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. 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. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that 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.
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. 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. Published languages are not converted into drafts; 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.
9
9
 
10
10
  Parse the arguments supplied with this command into this tool's parameters:
11
11
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.15.2] - 2026-09-11
11
+
12
+ ### Fixed
13
+ - **Discover and continue translation-only forward revisions (#297).** When core's
14
+ working-copy alias is absent or echoes live, node revision listing and guarded
15
+ updates consult Sentinel's translation inventory. Existing unpublished languages
16
+ continue through the governed endpoint with explicit language and live/working
17
+ preconditions. Published languages and ambiguous multilingual edits remain refused.
18
+ Permission and transport failures stop discovery rather than becoming “no draft”.
19
+ Update responses preserve the saved translation instead of replacing it with live
20
+ English. An unresolved revision conflict no longer prescribes revision surgery.
21
+
22
+ ## [2.15.1] - 2026-09-10
23
+
24
+ ### Fixed
25
+ - **`drupal_get_paragraph` honors `revisionId` and refuses a default-revision fallback (#292).**
26
+ Passing the host pin without `langcode` now reads JSON:API
27
+ `?resourceVersion=id:<vid>`. If Drupal serves a different
28
+ `drupal_internal__revision_id` (or nothing), the tool errors instead of
29
+ returning the live default revision. `ref.meta.target_revision_id` is the
30
+ served vid. `langcode` still uses Sentinel's draft read.
31
+ - **`drupal_list_nodes` no longer reports a Drupal-capped page as the exact collection total (#291).**
32
+ Core JSON:API silently caps `page[limit]` at 50 and does not send `meta.count`.
33
+ The connector was treating that page length as `total` with `approximate: false`,
34
+ so a `limit: 100` call on a 54-item collection returned 50 and looked complete.
35
+ `listEntities` now follows `links.next` to fill a larger requested limit (up to
36
+ 1000) and only reports an exact `total` when `meta.count` is present or the
37
+ window reached the end. Otherwise `approximate` is true and `hasNext` is
38
+ returned on the tool result.
39
+
10
40
  ## [2.15.0] - 2026-09-10
11
41
 
12
42
  ### Fixed
@@ -1553,6 +1583,9 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1553
1583
  - User tools gained explicit PII-access assertions.
1554
1584
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1555
1585
 
1586
+ [2.15.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.2
1587
+ [2.15.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.1
1588
+ [2.15.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.0
1556
1589
  [2.14.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.2
1557
1590
  [2.14.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.1
1558
1591
  [2.14.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # drupal-mcp-connector
2
2
 
3
- > A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL access, governed content tools, audit reports, and an SSH Drush bridge.
3
+ > A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL access, governed draft translations, content tools, audit reports, and an SSH Drush bridge.
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-green)](https://nodejs.org)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.15.0",
4
- "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
3
+ "version": "2.15.2",
4
+ "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL access, governed draft translations, content tools, audit reports, and an SSH Drush bridge.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
@@ -24,14 +24,30 @@ import { isPositiveNid, normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../path-a
24
24
  // governed read/write workflows explicitly need.
25
25
  const INTERNAL_ATTR_RE = /^drupal_internal__/;
26
26
 
27
- // countEntities() pagination. Drupal core JSON:API returns no total in `meta`,
28
- // so an exact count is obtained by walking pages until `links.next` is gone.
29
- // COUNT_PAGE_SIZE is Drupal's default max page size; COUNT_MAX_RECORDS bounds
30
- // the walk so a huge collection can't issue unbounded requests (mirrors the
31
- // GraphQL backend's MAX_CLIENT_RECORDS) — past it the count is approximate.
27
+ // countEntities() / listEntities() pagination. Drupal core JSON:API returns no
28
+ // total in `meta` and silently caps `page[limit]` at OffsetPage::SIZE_MAX
29
+ // (50 by default). COUNT_PAGE_SIZE matches that default; COUNT_MAX_RECORDS
30
+ // bounds a walk so a huge collection can't issue unbounded requests (mirrors
31
+ // the GraphQL backend's MAX_CLIENT_RECORDS) — past it the count is approximate.
32
32
  const COUNT_PAGE_SIZE = 50;
33
33
  const COUNT_MAX_RECORDS = 1000;
34
34
 
35
+ /**
36
+ * Whether a JSON:API collection document advertises another page.
37
+ * `links.next` may be a string href or a `{ href }` link object.
38
+ * @param {?object} data JSON:API document.
39
+ * @returns {boolean}
40
+ */
41
+ function jsonApiHasNext(data) {
42
+ const next = data?.links?.next;
43
+ if (next === undefined || next === null || next === false) return false;
44
+ if (typeof next === "string") return next.length > 0;
45
+ if (typeof next === "object" && next.href !== undefined && next.href !== null) {
46
+ return String(next.href).length > 0;
47
+ }
48
+ return Boolean(next);
49
+ }
50
+
35
51
  /**
36
52
  * Detect the JSON:API error Drupal returns when a write attempts to set the
37
53
  * `status` (published) field on a content_moderation-governed entity. Such
@@ -268,24 +284,72 @@ export class JsonApiBackend extends Backend {
268
284
  }
269
285
 
270
286
  /**
271
- * List entities for a descriptor. Server-side filter/sort/paging means the
272
- * result is always exact (`approximate`/`truncated` are false).
287
+ * List entities for a descriptor.
288
+ *
289
+ * Drupal core JSON:API has no `meta.count` (jsonapi_extras can add it) and
290
+ * silently caps `page[limit]` at OffsetPage::SIZE_MAX (50 by default). When
291
+ * the caller asks for more rows than one Drupal page returns and
292
+ * `links.next` is present, this method follows that link until the requested
293
+ * window is filled, the collection ends, or COUNT_MAX_RECORDS is hit.
294
+ * `page.total` is exact when `meta.count` is present or this window reached
295
+ * the end; otherwise it is the number of rows seen so far and `approximate`
296
+ * is true. Never report a single page's length as an exact collection total.
297
+ *
273
298
  * @param {import("../canonical.js").QueryDescriptor} descriptor
274
299
  * @returns {Promise<import("./backend-interface.js").ListResult>}
275
300
  */
276
301
  async listEntities(descriptor) {
277
- const params = this.compileQuery(descriptor);
278
- const qs = params.toString();
279
- const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
280
- const path = qs ? `${base}?${qs}` : base;
281
- const data = await drupalFetch(this.site, path);
282
- const entities = (data.data || []).map((r) => this.toCanonical(r));
283
- const total = data.meta?.count ?? entities.length;
302
+ const requestedLimit = descriptor.page?.limit;
303
+ const startOffset = descriptor.page?.offset ?? 0;
304
+ const fillTo = typeof requestedLimit === "number"
305
+ ? Math.min(Math.max(0, requestedLimit), COUNT_MAX_RECORDS)
306
+ : null;
307
+
308
+ const entities = [];
309
+ let offset = startOffset;
310
+ let hasNext = false;
311
+ let metaCount = null;
312
+
313
+ for (;;) {
314
+ const remaining = fillTo === null ? requestedLimit : fillTo - entities.length;
315
+ const page = {
316
+ ...descriptor.page,
317
+ offset,
318
+ ...(typeof remaining === "number" ? { limit: remaining } : {}),
319
+ };
320
+ const params = this.compileQuery({ ...descriptor, page });
321
+ const qs = params.toString();
322
+ const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
323
+ const path = qs ? `${base}?${qs}` : base;
324
+ const data = await drupalFetch(this.site, path);
325
+ if (metaCount === null && typeof data?.meta?.count === "number") {
326
+ metaCount = data.meta.count;
327
+ }
328
+ const pageEntities = (data.data || []).map((r) => this.toCanonical(r));
329
+ entities.push(...pageEntities);
330
+ hasNext = jsonApiHasNext(data);
331
+
332
+ if (fillTo === null) break;
333
+ if (!hasNext || pageEntities.length === 0) break;
334
+ if (entities.length >= fillTo) break;
335
+ offset += pageEntities.length;
336
+ }
337
+
338
+ const truncated = fillTo !== null
339
+ && typeof requestedLimit === "number"
340
+ && entities.length < requestedLimit
341
+ && hasNext;
342
+ const seen = startOffset + entities.length;
343
+ const exact = typeof metaCount === "number" || !hasNext;
284
344
  return {
285
345
  entities,
286
- page: { total, hasNext: Boolean(data.links?.next), cursor: null },
287
- approximate: false,
288
- truncated: false,
346
+ page: {
347
+ total: typeof metaCount === "number" ? metaCount : seen,
348
+ hasNext,
349
+ cursor: null,
350
+ },
351
+ approximate: !exact,
352
+ truncated,
289
353
  };
290
354
  }
291
355
 
@@ -0,0 +1,47 @@
1
+ /** Sentinel discovery for translation-only node revisions (#297). */
2
+ import { readTranslationInventory } from "./draft-write.js";
3
+
4
+ /**
5
+ * Read optional inventory, falling back only when the endpoint is unsupported.
6
+ * Permission, transport and malformed-response failures are not absence.
7
+ * @param {object} backend
8
+ * @param {{entityType: string, bundle: string, id: string}} ref
9
+ * @returns {Promise<object|null>}
10
+ */
11
+ export async function readNodeDraftInventory(backend, ref) {
12
+ if (ref.entityType !== "node" || typeof backend.rawQuery !== "function"
13
+ || typeof backend.resourcePath !== "function") return null;
14
+ let inventory;
15
+ try {
16
+ inventory = await readTranslationInventory(backend, ref);
17
+ } catch (error) {
18
+ if (/does not provide Sentinel's governed draft-translation endpoint/.test(error.message)) return null;
19
+ throw error;
20
+ }
21
+ const validVid = (vid) => /^[1-9]\d*$/.test(String(vid ?? "")) && Number.isSafeInteger(Number(vid));
22
+ if (!validVid(inventory.live?.vid)
23
+ || (inventory.working && (!validVid(inventory.working.vid)
24
+ || !Array.isArray(inventory.working.translations)
25
+ || inventory.working.translations.some((row) => !row || typeof row.langcode !== "string"
26
+ || typeof row.status !== "boolean")))) {
27
+ throw new Error("Sentinel returned an invalid node revision inventory. Re-read before updating.");
28
+ }
29
+ return inventory;
30
+ }
31
+
32
+ /**
33
+ * Ensure inventory discovery does not turn a published language into a draft.
34
+ * Sentinel still validates the revision pair and language on every request.
35
+ * @param {object} inventory
36
+ * @param {string|undefined} langcode
37
+ */
38
+ export function assertInventoryDraftLanguage(inventory, langcode) {
39
+ const rows = inventory.working?.translations ?? [];
40
+ if (!langcode && rows.length !== 1) {
41
+ throw new Error("This working revision contains translations. Pass an explicit langcode for an existing unpublished language; no draft was created.");
42
+ }
43
+ const row = langcode ? rows.find((item) => item.langcode === langcode) : rows[0];
44
+ if (!row || row.status !== false) {
45
+ throw new Error("The requested language is not an existing unpublished working draft. Published languages and other drafts were left unchanged.");
46
+ }
47
+ }
@@ -16,8 +16,8 @@
16
16
  * Sentinel's /mcp-draft endpoint with verified live/working revision IDs.
17
17
  * Core rejects resourceVersion on PATCH. Never retry the canonical URL.
18
18
  * - **#201** — the working-copy alias does not resolve, but core still
19
- * blocks. That is a stray revision row. Refuse with revision-surgery
20
- * language. `workingCopy: null` is not proof the node is writable.
19
+ * blocks. Consult Sentinel inventory for translation-only revisions before
20
+ * refusing the unresolved revision conflict. `workingCopy: null` is not proof the node is writable.
21
21
  *
22
22
  * Unsupported endpoints and stale revisions fail closed, without discard.
23
23
  */
@@ -25,6 +25,7 @@
25
25
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
26
26
  import { entityRevisionId } from "./write-revision.js";
27
27
  import { writeDraft } from "./draft-write.js";
28
+ import { readNodeDraftInventory, assertInventoryDraftLanguage } from "./node-draft-inventory.js";
28
29
 
29
30
  /** Stable error code for a core working-copy / not-latest-revision block. */
30
31
  export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
@@ -48,7 +49,7 @@ export const PATCH_BLOCKED_MESSAGE =
48
49
  "This entity cannot be updated over JSON:API because the stored entity is not " +
49
50
  "the latest revision (Drupal core #2795279). The JSON:API aliases " +
50
51
  "rel:latest-version and rel:working-copy cannot show the blocking row. " +
51
- "Clearing it requires revision surgery outside JSON:API (Drush / the entity API). " +
52
+ "Inspect the translation inventory and revision history before considering repair; do not delete drafts. " +
52
53
  "See connector #201. Do not retry the same canonical PATCH.";
53
54
 
54
55
  /**
@@ -248,17 +249,32 @@ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
248
249
  */
249
250
  export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
250
251
  const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
252
+ if (workingCopy?.id && workingCopy.id !== id) {
253
+ throw new PatchTargetAmbiguousError(id, workingCopy.id);
254
+ }
251
255
  let liveVid = entityRevisionId(existing);
252
256
  if (workingCopy && (liveVid === null || liveVid === undefined) && typeof backend?.getEntity === "function") {
253
257
  const live = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
254
258
  liveVid = entityRevisionId(live);
255
259
  }
260
+ const aliasVid = entityRevisionId(workingCopy);
261
+ if (!workingCopy || (liveVid !== null && String(aliasVid) === String(liveVid))) {
262
+ const inventory = await readNodeDraftInventory(backend, { entityType, bundle, id });
263
+ if (inventory) {
264
+ if (liveVid !== null && String(liveVid) !== String(inventory.live.vid)) {
265
+ throw new WorkingCopyStaleError(new Error("Sentinel's live revision changed during discovery."));
266
+ }
267
+ liveVid = Number(inventory.live.vid);
268
+ const workingVid = inventory.working ? Number(inventory.working.vid) : null;
269
+ if (workingVid !== null && workingVid !== liveVid) {
270
+ return { resourceVersion: `id:${workingVid}`, workingCopy: null,
271
+ liveVid, workingVid, inventory };
272
+ }
273
+ }
274
+ }
256
275
  if (!workingCopy) {
257
276
  return { resourceVersion: undefined, workingCopy: null, liveVid, workingVid: null };
258
277
  }
259
- if (workingCopy.id && workingCopy.id !== id) {
260
- throw new PatchTargetAmbiguousError(id, workingCopy.id);
261
- }
262
278
  const workingVid = entityRevisionId(workingCopy);
263
279
  if (workingVid !== null && liveVid !== null && String(workingVid) === String(liveVid)) {
264
280
  return { resourceVersion: undefined, workingCopy, liveVid, workingVid };
@@ -402,6 +418,7 @@ export async function prepareGuardedPatch(backend, {
402
418
  throw new StaleCopyError();
403
419
  }
404
420
  }
421
+ if (target.inventory) assertInventoryDraftLanguage(target.inventory, langcode);
405
422
  if (langcode) {
406
423
  if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
407
424
  throw new Error(
@@ -65,7 +65,10 @@ export async function attachWrittenRevisionPair({
65
65
  backend, entityType, bundle, id, entity, liveVid,
66
66
  }) {
67
67
  if (liveVid === null || liveVid === undefined || !entity) return entity;
68
- let workingVid = null;
68
+ let workingVid = entityRevisionId(entity);
69
+ if (workingVid !== null && String(workingVid) !== String(liveVid)) {
70
+ return attachRevisionPair(entity, { live: liveVid, working: workingVid });
71
+ }
69
72
  if (typeof backend?.getEntity === "function") {
70
73
  try {
71
74
  const wc = await backend.getEntity({
@@ -93,12 +96,16 @@ export async function attachWrittenRevisionPair({
93
96
  * for the write (e.g. `rel:working-copy`). PreferCanonical re-reads this
94
97
  * resource instead of the default revision so a draft PATCH is not
95
98
  * replaced by the live body.
99
+ * @param {?string} [args.langcode] Language returned by governed draft continuation.
96
100
  * @returns {Promise<object>} Entity to return, with `_revision` when relevant.
97
101
  */
98
102
  export async function readWrittenRevision({
99
103
  backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
100
- resourceVersion,
104
+ resourceVersion, langcode,
101
105
  }) {
106
+ // Sentinel returned the requested translation; a canonical read would replace
107
+ // it with live English, and the core working-copy alias may not address it.
108
+ if (langcode && patchResult) return patchResult;
102
109
  if (!relationshipsSent) {
103
110
  if (preferCanonical && typeof backend.getEntity === "function") {
104
111
  const fresh = await backend.getEntity({
@@ -136,6 +136,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
136
136
  const written = await readWrittenRevision({
137
137
  backend, entityType, bundle, id,
138
138
  relationshipsSent: relationshipsWereSent(resolvedRelationships),
139
+ langcode,
139
140
  patchResult: result,
140
141
  preferCanonical: false,
141
142
  });
@@ -280,8 +280,9 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
280
280
  *
281
281
  * @param {object} args - { site?, type, status?, filters?, limit?, offset?, sort? }.
282
282
  * A `status` boolean is appended to `filters` as a status equality descriptor.
283
- * @returns {Promise<{total: number, approximate: boolean, offset: number,
284
- * nextOffset: number, nodes: object[]}>} Paged, redacted node list.
283
+ * @returns {Promise<{total: number, approximate: boolean, truncated: boolean,
284
+ * offset: number, nextOffset: number, hasNext: boolean, nodes: object[]}>}
285
+ * Paged, redacted node list. `hasNext` is the backend's `links.next` signal.
285
286
  */
286
287
  async function listNodes({ site: siteName, type, status, filters = [], limit = 20, offset = 0, sort = [{ field: "changed", dir: "desc" }] }) {
287
288
  const site = getSiteConfig(siteName);
@@ -295,8 +296,10 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
295
296
  return {
296
297
  total: res.page?.total ?? nodes.length,
297
298
  approximate: res.approximate ?? false,
299
+ truncated: res.truncated ?? false,
298
300
  offset,
299
301
  nextOffset: offset + nodes.length,
302
+ hasNext: Boolean(res.page?.hasNext),
300
303
  nodes,
301
304
  };
302
305
  }
@@ -466,16 +469,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
466
469
  let fresh = await readWrittenRevision({
467
470
  backend, entityType: "node", bundle: type, id,
468
471
  relationshipsSent: relationshipsWereSent(resolvedRelationships),
472
+ langcode,
469
473
  patchResult: patched,
470
474
  preferCanonical: true,
471
475
  resourceVersion: patchTarget.resourceVersion,
472
476
  });
473
477
  const intendedAlias = pathAttr?.alias ? normalizeAlias(pathAttr.alias) : null;
474
478
  if (intendedAlias) {
475
- fresh = await assertAliasPersisted({
479
+ const aliasChecked = await assertAliasPersisted({
476
480
  backend, sec, type, id, intendedAlias,
477
481
  resourceVersion: patchTarget.resourceVersion, written: fresh,
478
482
  });
483
+ fresh = langcode ? { ...fresh, url: aliasChecked.url } : aliasChecked;
479
484
  }
480
485
  // Redirect only after the new alias is what re-reads show (#274).
481
486
  const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
@@ -528,7 +533,7 @@ export const definitions = [
528
533
  },
529
534
  {
530
535
  name: "drupal_list_nodes",
531
- description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors.",
536
+ description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors. Drupal core JSON:API caps page[limit] at 50; a larger requested limit is filled by following links.next (up to 1000). When the site does not expose meta.count, total is exact only if this window reached the end of the collection; otherwise approximate is true and hasNext is set.",
532
537
  inputSchema: {
533
538
  type: "object", required: ["type"],
534
539
  properties: {
@@ -579,7 +584,7 @@ export const definitions = [
579
584
  },
580
585
  {
581
586
  name: "drupal_update_node",
582
- 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. 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. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that 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.",
587
+ 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. 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. Published languages are not converted into drafts; 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.",
583
588
  inputSchema: {
584
589
  type: "object", required: ["type", "id"],
585
590
  properties: {
@@ -145,24 +145,85 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
145
145
  return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
146
146
  }
147
147
 
148
+ /**
149
+ * JSON:API `resourceVersion` selector for a paragraph host pin.
150
+ * Accepts a numeric vid, a numeric string, or an explicit `id:<vid>`.
151
+ * @param {string|number} revisionId Host pin / paragraph revision id.
152
+ * @returns {string} e.g. `id:6654`.
153
+ * @throws {Error} If the value is not a usable revision id.
154
+ */
155
+ export function paragraphResourceVersion(revisionId) {
156
+ if (typeof revisionId === "number" && Number.isFinite(revisionId) && revisionId > 0) {
157
+ return `id:${revisionId}`;
158
+ }
159
+ if (typeof revisionId === "string" && revisionId.length) {
160
+ if (/^id:[1-9]\d*$/.test(revisionId)) return revisionId;
161
+ if (/^[1-9]\d*$/.test(revisionId)) return `id:${revisionId}`;
162
+ }
163
+ throw new Error(
164
+ `Invalid paragraph revisionId "${revisionId}". Use a numeric vid (the host pin).`,
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Numeric vid from a host pin (`6654` or `id:6654`).
170
+ * @param {string|number} revisionId
171
+ * @returns {?number}
172
+ */
173
+ function requestedParagraphVid(revisionId) {
174
+ const raw = String(revisionId).replace(/^id:/, "");
175
+ const n = Number(raw);
176
+ return Number.isFinite(n) && n > 0 ? n : null;
177
+ }
178
+
179
+ /**
180
+ * Fail closed when a requested paragraph revision was not the one served.
181
+ * Silent substitution of the default revision is the defect this guards (#292).
182
+ * @param {?object} entity Canonical paragraph, or null if the read missed.
183
+ * @param {string|number} requested Host pin that was asked for.
184
+ * @param {string} id Paragraph UUID.
185
+ * @returns {object} `entity` when the served vid matches.
186
+ * @throws {Error} On a miss or a mismatched vid.
187
+ */
188
+ function requireServedParagraphRevision(entity, requested, id) {
189
+ const wanted = requestedParagraphVid(requested);
190
+ const served = paragraphRevisionId(entity);
191
+ if (!entity || wanted === null || served === null || served !== wanted) {
192
+ const got = !entity || served === null ? "no revision" : String(served);
193
+ throw new Error(
194
+ `Requested paragraph revision ${wanted ?? requested} for ${id} but the backend served ${got}. ` +
195
+ "Refusing to substitute a different revision.",
196
+ );
197
+ }
198
+ return entity;
199
+ }
200
+
148
201
  /**
149
202
  * Fetch a single paragraph by bundle + UUID, redacted per the site policy, and
150
203
  * annotate it with the embedding ref (including `meta.target_revision_id`).
151
204
  *
152
- * @param {object} args - { site?, paragraphType, id }.
205
+ * When `revisionId` is set (the host's `meta.target_revision_id`), the read
206
+ * addresses that revision via JSON:API `?resourceVersion=id:<vid>` and errors
207
+ * if Drupal serves a different vid. Omitting `revisionId` still reads the
208
+ * default revision.
209
+ *
210
+ * @param {object} args - { site?, paragraphType, id, langcode?, revisionId? }.
153
211
  * @returns {Promise<(object & {ref: object})|null>}
154
- * The redacted paragraph with an embedding `ref`, or null if not found.
212
+ * The redacted paragraph with an embedding `ref`, or null if not found and
213
+ * no `revisionId` was requested.
155
214
  * @throws {SecurityError} If reading paragraphs of this bundle is not permitted.
215
+ * @throws {Error} If a requested `revisionId` cannot be served.
156
216
  */
157
217
  async function getParagraph({ site: siteName, paragraphType, id, langcode, revisionId }) {
158
218
  const site = getSiteConfig(siteName);
159
219
  const sec = resolveSecurityConfig(site);
160
220
  assertReadAllowed(sec, "paragraph", paragraphType);
161
221
  const backend = await resolveBackend(site);
222
+ const pinRequested = revisionId !== undefined && revisionId !== null && revisionId !== "";
162
223
  let entity;
163
224
  if (langcode) {
164
225
  const targetLang = assertDraftLangcode(langcode);
165
- const pinned = revisionId ?? paragraphRevisionId(
226
+ const pinned = pinRequested ? revisionId : paragraphRevisionId(
166
227
  await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id }),
167
228
  );
168
229
  if (pinned === null || pinned === undefined || pinned === "") {
@@ -172,12 +233,18 @@ async function getParagraph({ site: siteName, paragraphType, id, langcode, revis
172
233
  entityType: "paragraph", bundle: paragraphType, id, langcode: targetLang,
173
234
  draftRevision: { revisionId: pinned },
174
235
  });
236
+ } else if (pinRequested) {
237
+ const resourceVersion = paragraphResourceVersion(revisionId);
238
+ entity = await backend.getEntity({
239
+ entityType: "paragraph", bundle: paragraphType, id, resourceVersion,
240
+ });
241
+ requireServedParagraphRevision(entity, revisionId, id);
175
242
  } else {
176
243
  entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
177
244
  }
178
245
  if (!entity) return null;
179
246
  const redacted = redactCanonicalEntity(entity, sec, "paragraph");
180
- const resolvedRevisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted) ?? revisionId;
247
+ const resolvedRevisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted);
181
248
  return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id, resolvedRevisionId) };
182
249
  }
183
250
 
@@ -219,7 +286,7 @@ export const definitions = [
219
286
  {
220
287
  name: "drupal_get_paragraph",
221
288
  description:
222
- "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. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
289
+ "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. Pass revisionId (the host pin) to read that revision via JSON:API resourceVersion=id:<vid>; a mismatch or miss is an error, not a silent default-revision fallback. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
223
290
  inputSchema: {
224
291
  type: "object", required: ["paragraphType", "id"],
225
292
  properties: {
@@ -227,7 +294,7 @@ export const definitions = [
227
294
  paragraphType: { type: "string", description: "Paragraph type / bundle machine name" },
228
295
  id: { type: "string", description: "Paragraph UUID" },
229
296
  langcode: { type: "string", description: "Target language for the unpublished working translation (e.g. 'es')." },
230
- revisionId: { type: "string", description: "Paragraph revision id the host already pins." },
297
+ revisionId: { type: "string", description: "Paragraph revision id the host already pins (ERR meta.target_revision_id). Honored on the read; a different served vid is an error." },
231
298
  },
232
299
  },
233
300
  },
@@ -23,6 +23,7 @@ import { resolveBackend } from "../lib/backends/index.js";
23
23
  import {
24
24
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
25
25
  } from "../lib/security.js";
26
+ import { readNodeDraftInventory } from "../lib/node-draft-inventory.js";
26
27
  import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
27
28
  import { omitLiveComputedMetatag } from "../lib/entity-response.js";
28
29
 
@@ -177,13 +178,28 @@ async function listRevisions({ site: siteName, type, id }) {
177
178
 
178
179
  // The working-copy alias only resolves on entities under a content_moderation
179
180
  // workflow with a pending forward revision; absence is expected, not an error.
180
- const workingCopy = await fetchRevisionResource(backend, "node", type, id, "rel:working-copy")
181
+ let workingCopy = await fetchRevisionResource(backend, "node", type, id, "rel:working-copy")
181
182
  .then(summarizeRevision)
182
183
  .catch(() => null);
183
184
 
185
+ if (!workingCopy || String(workingCopy.vid) === String(latestVersion?.vid)) {
186
+ const inventory = await readNodeDraftInventory(backend, { entityType: "node", bundle: type, id });
187
+ if (inventory?.working && String(inventory.working.vid) !== String(inventory.live.vid)) {
188
+ if (latestVersion && String(latestVersion.vid) !== String(inventory.live.vid)) {
189
+ throw new Error("The live revision changed during discovery. Re-read the revision inventory.");
190
+ }
191
+ // No aggregate publication flag: English may be published while Spanish is draft.
192
+ workingCopy = { vid: Number(inventory.working.vid), source: "sentinel-inventory",
193
+ translations: inventory.working.translations.map(({ langcode, status, moderation_state, default: isDefault }) => ({
194
+ langcode, status, moderation_state, default: isDefault,
195
+ })) };
196
+ }
197
+ }
198
+
184
199
  const possiblyPatchBlocked = Boolean(latestVersion && changedAheadOfRevision(latestVersion));
185
200
  let note = LIST_REVISIONS_BASE_NOTE;
186
201
  if (!workingCopy) note += LIST_REVISIONS_NULL_WC_NOTE;
202
+ if (workingCopy?.source === "sentinel-inventory") note += " Working revision discovered through Sentinel; publication state is language-specific. Continue an unpublished translation with explicit langcode.";
187
203
  if (possiblyPatchBlocked) note += LIST_REVISIONS_FINGERPRINT_NOTE;
188
204
 
189
205
  return {
@@ -332,7 +348,7 @@ export const definitions = [
332
348
  {
333
349
  name: "drupal_list_revisions",
334
350
  description:
335
- "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.",
351
+ "Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. 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.",
336
352
  inputSchema: {
337
353
  type: "object", required: ["type", "id"],
338
354
  properties: {