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.
- package/.agents/commands/drupal-get-paragraph.md +3 -3
- package/.agents/commands/drupal-list-nodes.md +2 -2
- package/.agents/commands/drupal-list-revisions.md +2 -2
- package/.agents/commands/drupal-update-node.md +2 -2
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/lib/backends/jsonapi.js +81 -17
- package/src/lib/node-draft-inventory.js +47 -0
- package/src/lib/patch-preflight.js +23 -6
- package/src/lib/write-revision.js +9 -2
- package/src/tools/entities.js +1 -0
- package/src/tools/nodes.js +10 -5
- package/src/tools/paragraphs.js +73 -6
- package/src/tools/revisions.js +18 -2
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](https://nodejs.org)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.15.
|
|
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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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.
|
|
272
|
-
*
|
|
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
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
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: {
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
20
|
-
*
|
|
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
|
-
"
|
|
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 =
|
|
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({
|
package/src/tools/entities.js
CHANGED
|
@@ -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
|
});
|
package/src/tools/nodes.js
CHANGED
|
@@ -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,
|
|
284
|
-
* nextOffset: number, nodes: object[]}>}
|
|
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
|
-
|
|
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.
|
|
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: {
|
package/src/tools/paragraphs.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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)
|
|
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
|
},
|
package/src/tools/revisions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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: {
|