drupal-mcp-connector 2.14.1 → 2.14.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-create-translation.md +6 -5
- package/.agents/commands/drupal-entity-update.md +2 -1
- package/.agents/commands/drupal-get-node.md +4 -3
- package/.agents/commands/drupal-get-revision.md +4 -3
- package/.agents/commands/drupal-list-translations.md +3 -3
- package/.agents/commands/drupal-update-node.md +4 -3
- package/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/src/lib/draft-write.js +183 -19
- package/src/lib/patch-preflight.js +15 -1
- package/src/tools/entities.js +4 -1
- package/src/tools/nodes.js +38 -5
- package/src/tools/revisions.js +34 -3
- package/src/tools/translations.js +123 -78
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Create
|
|
3
|
-
argument-hint: "<type> <id> <langcode> [site] [entityType] [attributes]"
|
|
2
|
+
description: "Create a translation as an unpublished non-default draft revision (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. English live title, body, status, alias, and default revision stay unchanged. An existing translation is a conflict, not an overwrite. Continue the draft with drupal_update_node and langcode. Requires Sentinel's draft-translation endpoint and a translatable bundle. Paragraph field values are not translated on this path. Defaults to node. Publication stays denied for content-tier callers."
|
|
3
|
+
argument-hint: "<type> <id> <langcode> [site] [entityType] [attributes] [dryRun]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_create_translation`.
|
|
7
7
|
|
|
8
|
-
Create
|
|
8
|
+
Create a translation as an unpublished non-default draft revision (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. English live title, body, status, alias, and default revision stay unchanged. An existing translation is a conflict, not an overwrite. Continue the draft with drupal_update_node and langcode. Requires Sentinel's draft-translation endpoint and a translatable bundle. Paragraph field values are not translated on this path. Defaults to node. Publication stays denied for content-tier callers.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
12
12
|
**Required:**
|
|
13
|
-
- `type` (string): Bundle machine name, e.g. '
|
|
13
|
+
- `type` (string): Bundle machine name, e.g. 'basic_page'
|
|
14
14
|
- `id` (string): Entity UUID
|
|
15
|
-
- `langcode` (string): Target language code, e.g. '
|
|
15
|
+
- `langcode` (string): Target language code, e.g. 'es', 'de', 'pt-br'
|
|
16
16
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `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).
|
|
19
19
|
- `entityType` (string): Entity type machine name. Default: 'node'.
|
|
20
20
|
- `attributes` (object (pass as JSON)): Translated field values keyed by Drupal machine name
|
|
21
|
+
- `dryRun` (boolean (true/false)): Validate without saving
|
|
21
22
|
|
|
22
23
|
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,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents."
|
|
3
|
-
argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun] [returning]"
|
|
3
|
+
argument-hint: "<entityType> <bundle> <id> [site] [langcode] [attributes] [relationships] [dryRun] [returning]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_entity_update`.
|
|
@@ -16,6 +16,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
16
16
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `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).
|
|
19
|
+
- `langcode` (string): Target language for an unpublished working translation (nodes). Continues that translation via Sentinel.
|
|
19
20
|
- `attributes` (object (pass as JSON))
|
|
20
21
|
- `relationships` (object (pass as JSON))
|
|
21
22
|
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes."
|
|
3
|
-
argument-hint: "<type> <id> [site]"
|
|
2
|
+
description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes. Pass langcode to read a working translation draft via Sentinel (distinct from published English)."
|
|
3
|
+
argument-hint: "<type> <id> [site] [langcode]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_get_node`.
|
|
7
7
|
|
|
8
|
-
Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes.
|
|
8
|
+
Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes. Pass langcode to read a working translation draft via Sentinel (distinct from published English).
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -15,5 +15,6 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
15
15
|
|
|
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
|
+
- `langcode` (string): Target language (e.g. 'es') to read the unpublished working translation instead of the default language.
|
|
18
19
|
|
|
19
20
|
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: "Fetch a single revision of a content node by version id or alias. `version` may be a numeric vid (e.g. 42), an explicit 'id:<vid>', or the relative aliases 'rel:latest-version' / 'rel:working-copy'. Read-only; attributes are redacted per security policy."
|
|
3
|
-
argument-hint: "<type> <id> <version> [site]"
|
|
2
|
+
description: "Fetch a single revision of a content node by version id or alias. `version` may be a numeric vid (e.g. 42), an explicit 'id:<vid>', or the relative aliases 'rel:latest-version' / 'rel:working-copy'. Pass langcode to read the unpublished working translation of that node (Sentinel). Read-only; attributes are redacted per security policy."
|
|
3
|
+
argument-hint: "<type> <id> <version> [site] [langcode]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_get_revision`.
|
|
7
7
|
|
|
8
|
-
Fetch a single revision of a content node by version id or alias. `version` may be a numeric vid (e.g. 42), an explicit 'id:<vid>', or the relative aliases 'rel:latest-version' / 'rel:working-copy'. Read-only; attributes are redacted per security policy.
|
|
8
|
+
Fetch a single revision of a content node by version id or alias. `version` may be a numeric vid (e.g. 42), an explicit 'id:<vid>', or the relative aliases 'rel:latest-version' / 'rel:working-copy'. Pass langcode to read the unpublished working translation of that node (Sentinel). Read-only; attributes are redacted per security policy.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -16,5 +16,6 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
16
16
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `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).
|
|
19
|
+
- `langcode` (string): Target language for the unpublished working translation (e.g. 'es').
|
|
19
20
|
|
|
20
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,16 +1,16 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "List
|
|
2
|
+
description: "List live and working translation langcodes for a Drupal node. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft). Core JSON:API alone serves one language and cannot prove others are absent. Defaults to node."
|
|
3
3
|
argument-hint: "<type> <id> [site] [entityType]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_list_translations`.
|
|
7
7
|
|
|
8
|
-
List
|
|
8
|
+
List live and working translation langcodes for a Drupal node. Uses Sentinel's translation inventory when available (live default revision vs unpublished working draft). Core JSON:API alone serves one language and cannot prove others are absent. Defaults to node.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
12
12
|
**Required:**
|
|
13
|
-
- `type` (string): Bundle machine name, e.g. '
|
|
13
|
+
- `type` (string): Bundle machine name, e.g. 'basic_page'
|
|
14
14
|
- `id` (string): Entity UUID
|
|
15
15
|
|
|
16
16
|
**Optional:**
|
|
@@ -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. 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."
|
|
3
|
-
argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
|
|
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."
|
|
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. 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. 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.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -21,6 +21,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
21
21
|
- `format` (string): Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'.
|
|
22
22
|
- `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
|
|
23
23
|
- `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'.
|
|
24
|
+
- `langcode` (string): Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode.
|
|
24
25
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here.
|
|
25
26
|
- `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot.
|
|
26
27
|
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.14.2] - 2026-09-10
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **`langcode` reads target only an unpublished working translation.**
|
|
14
|
+
After a Spanish draft exists, English on that working revision is still
|
|
15
|
+
the published copy. `drupal_get_node` / `drupal_get_revision` with
|
|
16
|
+
`langcode` now read the working translation only when that language is
|
|
17
|
+
unpublished, so they do not treat published English as a draft.
|
|
18
|
+
- **Spanish (and other) draft translations beside published English (#276).**
|
|
19
|
+
`drupal_create_translation` no longer PATCHes `langcode` on the canonical
|
|
20
|
+
entity. It POSTs Sentinel's `/mcp-draft/translations` endpoint so Drupal
|
|
21
|
+
calls `addTranslation()` on an unpublished forward revision. The live
|
|
22
|
+
default language, revision id, title, body, status, and alias stay
|
|
23
|
+
unchanged. An existing translation is a conflict. Continue the draft with
|
|
24
|
+
`drupal_update_node` + `langcode`; read it with `drupal_get_node` /
|
|
25
|
+
`drupal_list_translations` / `drupal_get_revision` + `langcode`. Requires
|
|
26
|
+
MCP Sentinel with the draft-translation contract. Paragraph field-value
|
|
27
|
+
translation and media photo-alt translation remain pending. A content-tier
|
|
28
|
+
caller still cannot publish through this path.
|
|
29
|
+
|
|
10
30
|
## [2.14.1] - 2026-09-09
|
|
11
31
|
|
|
12
32
|
### Changed
|
|
@@ -1481,6 +1501,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
1481
1501
|
- User tools gained explicit PII-access assertions.
|
|
1482
1502
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
1483
1503
|
|
|
1504
|
+
[2.14.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.2
|
|
1484
1505
|
[2.14.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.1
|
|
1485
1506
|
[2.14.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.0
|
|
1486
1507
|
[2.13.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.1
|
package/package.json
CHANGED
package/src/lib/draft-write.js
CHANGED
|
@@ -1,8 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sentinel's governed draft-continuation contract (d.o #3621022).
|
|
2
|
+
* Sentinel's governed draft-continuation contract (d.o #3621022 / GitHub #176).
|
|
3
3
|
* Core JSON:API revision selectors support reads, not PATCH requests.
|
|
4
|
+
* Translation create/update uses the same surface with X-MCP-Draft-Langcode.
|
|
4
5
|
*/
|
|
5
6
|
|
|
7
|
+
const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
|
|
8
|
+
const MISSING_DRAFT_ENDPOINT =
|
|
9
|
+
"The site does not provide Sentinel's governed draft endpoint (d.o #3621022). " +
|
|
10
|
+
"Update the server-side module; the draft was not discarded and no canonical fallback was attempted.";
|
|
11
|
+
const MISSING_TRANSLATION_ENDPOINT =
|
|
12
|
+
"The site does not provide Sentinel's governed draft-translation endpoint. " +
|
|
13
|
+
"Update MCP Sentinel; no canonical langcode PATCH was attempted.";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {unknown} error
|
|
17
|
+
* @param {string} message
|
|
18
|
+
* @returns {Error}
|
|
19
|
+
*/
|
|
20
|
+
function missingEndpointError(error, message) {
|
|
21
|
+
if (/Drupal (404|405)\b/.test(String(error?.message))) {
|
|
22
|
+
return new Error(message, { cause: error });
|
|
23
|
+
}
|
|
24
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} langcode
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function assertDraftLangcode(langcode) {
|
|
32
|
+
const value = String(langcode || "").trim();
|
|
33
|
+
if (!LANGCODE_RE.test(value)) {
|
|
34
|
+
throw new Error("A valid target langcode is required (for example 'es' or 'pt-br').");
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} [draftRevision]
|
|
41
|
+
* @returns {{live: string, working: string}}
|
|
42
|
+
*/
|
|
43
|
+
function requireWorkingPair(draftRevision) {
|
|
44
|
+
const live = String(draftRevision?.liveVid ?? "");
|
|
45
|
+
const working = String(draftRevision?.workingVid ?? "");
|
|
46
|
+
if (!/^[1-9]\d*$/.test(live) || !/^[1-9]\d*$/.test(working) || live === working) {
|
|
47
|
+
throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
|
|
48
|
+
}
|
|
49
|
+
return { live, working };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {object} backend
|
|
54
|
+
* @param {string} entityType
|
|
55
|
+
* @param {string} bundle
|
|
56
|
+
* @param {string} id
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function nodeResource(backend, entityType, bundle, id) {
|
|
60
|
+
if (entityType !== "node") {
|
|
61
|
+
throw new Error("Governed draft translation is implemented for nodes.");
|
|
62
|
+
}
|
|
63
|
+
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
64
|
+
throw new Error("This backend does not support governed draft continuation.");
|
|
65
|
+
}
|
|
66
|
+
return `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
6
69
|
/**
|
|
7
70
|
* Validate or continue a draft, using the same payload and revision precondition.
|
|
8
71
|
* No canonical fallback: an absent endpoint or refused precondition stops work.
|
|
@@ -12,46 +75,147 @@
|
|
|
12
75
|
* @returns {Promise<object>} Preflight metadata or the written canonical entity.
|
|
13
76
|
*/
|
|
14
77
|
export async function writeDraft(backend, input, preflight = false) {
|
|
78
|
+
const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
|
|
79
|
+
const { live, working } = requireWorkingPair(draftRevision);
|
|
80
|
+
const base = nodeResource(backend, entityType, bundle, id);
|
|
81
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes };
|
|
82
|
+
if (relationships) data.relationships = relationships;
|
|
83
|
+
const headers = {
|
|
84
|
+
"If-Match": `"${live}:${working}"`,
|
|
85
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
86
|
+
};
|
|
87
|
+
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
88
|
+
if (targetLang) headers["X-MCP-Draft-Langcode"] = targetLang;
|
|
89
|
+
let result;
|
|
90
|
+
try {
|
|
91
|
+
result = await backend.rawQuery({
|
|
92
|
+
path: `${base}/mcp-draft`,
|
|
93
|
+
options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
|
|
94
|
+
});
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw missingEndpointError(error, MISSING_DRAFT_ENDPOINT);
|
|
97
|
+
}
|
|
98
|
+
if (preflight) {
|
|
99
|
+
if (result?.meta?.draft_preflight !== true
|
|
100
|
+
|| String(result.meta.live) !== live || String(result.meta.working) !== working) {
|
|
101
|
+
throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
|
|
102
|
+
}
|
|
103
|
+
if (targetLang && result.meta.langcode && String(result.meta.langcode) !== targetLang) {
|
|
104
|
+
throw new Error("The site did not confirm the requested translation language. Refusing to continue.");
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
109
|
+
throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
110
|
+
}
|
|
111
|
+
return backend.toCanonical(result.data);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create a target-language translation as an unpublished forward revision.
|
|
116
|
+
* If-Match is `"live"` when there is no working copy, or `"live:working"` when
|
|
117
|
+
* adding the language onto an existing unpublished English draft.
|
|
118
|
+
* @param {object} backend
|
|
119
|
+
* @param {object} input
|
|
120
|
+
* @param {boolean} [preflight]
|
|
121
|
+
* @returns {Promise<object>}
|
|
122
|
+
*/
|
|
123
|
+
export async function createTranslationDraft(backend, input, preflight = false) {
|
|
15
124
|
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
125
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
16
126
|
const live = String(draftRevision?.liveVid ?? "");
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
127
|
+
const workingRaw = draftRevision?.workingVid;
|
|
128
|
+
const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
|
|
129
|
+
? ""
|
|
130
|
+
: String(workingRaw);
|
|
131
|
+
if (entityType !== "node" || !/^[1-9]\d*$/.test(live)) {
|
|
132
|
+
throw new Error("Translation create requires a verified live node revision ID.");
|
|
21
133
|
}
|
|
22
|
-
if (
|
|
23
|
-
throw new Error("
|
|
134
|
+
if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
|
|
135
|
+
throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
|
|
24
136
|
}
|
|
25
|
-
const
|
|
137
|
+
const base = nodeResource(backend, entityType, bundle, id);
|
|
138
|
+
const safeAttributes = { ...attributes };
|
|
139
|
+
delete safeAttributes.langcode;
|
|
140
|
+
const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
|
|
26
141
|
if (relationships) data.relationships = relationships;
|
|
142
|
+
const ifMatch = working ? `"${live}:${working}"` : `"${live}"`;
|
|
27
143
|
let result;
|
|
28
144
|
try {
|
|
29
145
|
result = await backend.rawQuery({
|
|
30
|
-
path: `${
|
|
146
|
+
path: `${base}/mcp-draft/translations`,
|
|
31
147
|
options: {
|
|
32
|
-
method: "
|
|
148
|
+
method: "POST",
|
|
33
149
|
headers: {
|
|
34
|
-
"If-Match":
|
|
150
|
+
"If-Match": ifMatch,
|
|
35
151
|
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
152
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
36
153
|
},
|
|
37
154
|
body: JSON.stringify({ data }),
|
|
38
155
|
},
|
|
39
156
|
});
|
|
40
157
|
} catch (error) {
|
|
41
|
-
|
|
42
|
-
throw new Error("The site does not provide Sentinel's governed draft endpoint (d.o #3621022). Update the server-side module; the draft was not discarded and no canonical fallback was attempted.", { cause: error });
|
|
43
|
-
}
|
|
44
|
-
throw error;
|
|
158
|
+
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
45
159
|
}
|
|
46
160
|
if (preflight) {
|
|
47
|
-
if (result?.meta?.draft_preflight !== true
|
|
48
|
-
|
|
49
|
-
throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
|
|
161
|
+
if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
|
|
162
|
+
throw new Error("The site did not confirm a non-saving translation preflight. Refusing to continue.");
|
|
50
163
|
}
|
|
51
164
|
return result;
|
|
52
165
|
}
|
|
53
166
|
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
54
|
-
throw new Error("
|
|
167
|
+
throw new Error("Translation create response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
|
|
168
|
+
}
|
|
169
|
+
return backend.toCanonical(result.data);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read live/working translation inventory from Sentinel.
|
|
174
|
+
* @param {object} backend
|
|
175
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
176
|
+
* @returns {Promise<object>}
|
|
177
|
+
*/
|
|
178
|
+
export async function readTranslationInventory(backend, { entityType, bundle, id }) {
|
|
179
|
+
const base = nodeResource(backend, entityType, bundle, id);
|
|
180
|
+
try {
|
|
181
|
+
const result = await backend.rawQuery({ path: `${base}/mcp-translations` });
|
|
182
|
+
if (!result?.meta?.live) {
|
|
183
|
+
throw new Error("The site did not return a translation inventory.");
|
|
184
|
+
}
|
|
185
|
+
return result.meta;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read one unpublished working translation.
|
|
193
|
+
* @param {object} backend
|
|
194
|
+
* @param {object} input
|
|
195
|
+
* @returns {Promise<object>}
|
|
196
|
+
*/
|
|
197
|
+
export async function readDraftTranslation(backend, input) {
|
|
198
|
+
const { entityType, bundle, id, draftRevision } = input;
|
|
199
|
+
const langcode = assertDraftLangcode(input.langcode);
|
|
200
|
+
const { live, working } = requireWorkingPair(draftRevision);
|
|
201
|
+
const base = nodeResource(backend, entityType, bundle, id);
|
|
202
|
+
let result;
|
|
203
|
+
try {
|
|
204
|
+
result = await backend.rawQuery({
|
|
205
|
+
path: `${base}/mcp-draft`,
|
|
206
|
+
options: {
|
|
207
|
+
method: "GET",
|
|
208
|
+
headers: {
|
|
209
|
+
"If-Match": `"${live}:${working}"`,
|
|
210
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
|
|
216
|
+
}
|
|
217
|
+
if (!result?.data || result.data.id !== id) {
|
|
218
|
+
throw new Error("Draft translation read did not identify the requested entity.");
|
|
55
219
|
}
|
|
56
220
|
return backend.toCanonical(result.data);
|
|
57
221
|
}
|
|
@@ -300,11 +300,25 @@ export async function preflightPatchWritable({
|
|
|
300
300
|
* @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
|
|
301
301
|
*/
|
|
302
302
|
export async function prepareGuardedPatch(backend, {
|
|
303
|
-
entityType, bundle, id, existing, attributes, relationships,
|
|
303
|
+
entityType, bundle, id, existing, attributes, relationships, langcode,
|
|
304
304
|
}) {
|
|
305
305
|
const target = shouldPreflightPatch({ existing, attributes })
|
|
306
306
|
? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
|
|
307
307
|
: { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
|
|
308
|
+
if (langcode) {
|
|
309
|
+
if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"No unpublished working translation for this language. " +
|
|
312
|
+
"Create it with drupal_create_translation first; a canonical langcode PATCH is not attempted."
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
|
|
316
|
+
await writeDraft(backend, {
|
|
317
|
+
entityType, bundle, id, attributes, relationships, langcode,
|
|
318
|
+
draftRevision: target.draftRevision,
|
|
319
|
+
}, true);
|
|
320
|
+
return target;
|
|
321
|
+
}
|
|
308
322
|
if (target.resourceVersion) {
|
|
309
323
|
target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
|
|
310
324
|
await writeDraft(backend, {
|
package/src/tools/entities.js
CHANGED
|
@@ -96,7 +96,7 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
|
|
|
96
96
|
* @returns {Promise<object>} The updated entity descriptor.
|
|
97
97
|
* @throws {SecurityError} If updating the type/bundle is not permitted.
|
|
98
98
|
*/
|
|
99
|
-
async function updateEntity({ site: siteName, entityType, bundle, id, attributes = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
99
|
+
async function updateEntity({ site: siteName, entityType, bundle, id, attributes = {}, relationships = {}, langcode, dryRun = false, returning = "full" }) {
|
|
100
100
|
const site = getSiteConfig(siteName);
|
|
101
101
|
const sec = resolveSecurityConfig(site);
|
|
102
102
|
assertWriteAllowed(sec, "update", entityType, bundle);
|
|
@@ -119,6 +119,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
119
119
|
const resolvedRelationships = await resolveErrRelationships(backend, relationships);
|
|
120
120
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
121
121
|
entityType, bundle, id, existing, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
122
|
+
langcode,
|
|
122
123
|
});
|
|
123
124
|
if (dryRun) {
|
|
124
125
|
return {
|
|
@@ -128,6 +129,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
128
129
|
}
|
|
129
130
|
const result = await updateEntityGuarded(backend, {
|
|
130
131
|
entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
|
|
132
|
+
...(langcode ? { langcode } : {}),
|
|
131
133
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
132
134
|
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
133
135
|
});
|
|
@@ -289,6 +291,7 @@ export const definitions = [
|
|
|
289
291
|
entityType: { type: "string" },
|
|
290
292
|
bundle: { type: "string" },
|
|
291
293
|
id: { type: "string" },
|
|
294
|
+
langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
|
|
292
295
|
attributes: { type: "object" },
|
|
293
296
|
relationships: { type: "object" },
|
|
294
297
|
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
|
package/src/tools/nodes.js
CHANGED
|
@@ -18,6 +18,7 @@ import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } fro
|
|
|
18
18
|
import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
|
|
19
19
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
|
+
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
|
|
21
22
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
22
23
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
23
24
|
import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
|
|
@@ -176,12 +177,39 @@ function pageOf({ limit = 20, offset = 0 }) {
|
|
|
176
177
|
* @param {object} args - { site?, type, id }.
|
|
177
178
|
* @returns {Promise<object|null>} The redacted node, or null if not found.
|
|
178
179
|
*/
|
|
179
|
-
async function getNode({ site: siteName, type, id }) {
|
|
180
|
+
async function getNode({ site: siteName, type, id, langcode, resourceVersion }) {
|
|
180
181
|
const site = getSiteConfig(siteName);
|
|
181
182
|
const sec = resolveSecurityConfig(site);
|
|
182
183
|
assertReadAllowed(sec, "node", type);
|
|
183
184
|
const backend = await resolveBackend(site);
|
|
184
|
-
|
|
185
|
+
if (langcode) {
|
|
186
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
187
|
+
const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
|
|
188
|
+
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
189
|
+
if (workingRow && workingRow.status === false && inventory.live?.vid && inventory.working?.vid) {
|
|
190
|
+
const entity = await readDraftTranslation(backend, {
|
|
191
|
+
entityType: "node", bundle: type, id, langcode: targetLang,
|
|
192
|
+
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
193
|
+
});
|
|
194
|
+
return redactCanonicalEntity(entity, sec, "node");
|
|
195
|
+
}
|
|
196
|
+
const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
|
|
197
|
+
if (!liveHas) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (targetLang !== inventory.defaultLangcode) {
|
|
201
|
+
const liveRow = (inventory.live.translations ?? []).find((row) => row.langcode === targetLang);
|
|
202
|
+
return {
|
|
203
|
+
id, entityType: "node", bundle: type, langcode: targetLang,
|
|
204
|
+
title: liveRow?.title ?? null,
|
|
205
|
+
status: liveRow?.status ?? null,
|
|
206
|
+
fields: { moderation_state: liveRow?.moderation_state },
|
|
207
|
+
_revisions: { live: inventory.live.vid, working: inventory.working?.vid ?? null },
|
|
208
|
+
note: "Published non-default translations are listed on the live revision; full field reads of an unpublished working translation use langcode against the working draft.",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const entity = await backend.getEntity({ entityType: "node", bundle: type, id, resourceVersion });
|
|
185
213
|
return entity ? redactCanonicalEntity(entity, sec, "node") : null;
|
|
186
214
|
}
|
|
187
215
|
|
|
@@ -310,13 +338,14 @@ async function createNode({ site: siteName, type, title, body, summary, format,
|
|
|
310
338
|
* @param {object} args - { site?, type, id, title?, body?, summary?, format?, status?, moderationState?, fields?, relationships? }.
|
|
311
339
|
* @returns {Promise<object>} The updated node descriptor.
|
|
312
340
|
*/
|
|
313
|
-
async function updateNode({ site: siteName, type, id, title, body, summary, format, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
341
|
+
async function updateNode({ site: siteName, type, id, title, body, summary, format, status, moderationState, langcode, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
314
342
|
const site = getSiteConfig(siteName);
|
|
315
343
|
const sec = resolveSecurityConfig(site);
|
|
316
344
|
assertWriteAllowed(sec, "update", "node", type);
|
|
317
345
|
const backend = await resolveBackend(site);
|
|
318
346
|
const summaryWrite = await assertBodySummaryWritable(backend, type, summary);
|
|
319
347
|
let attributes = { ...fields };
|
|
348
|
+
delete attributes.langcode;
|
|
320
349
|
if (title !== undefined) attributes.title = title;
|
|
321
350
|
if (moderationState !== undefined) attributes.moderation_state = moderationState;
|
|
322
351
|
else if (status !== undefined) attributes.status = status;
|
|
@@ -348,6 +377,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
348
377
|
// governed continuation endpoint; stray revisions still fail the probe.
|
|
349
378
|
const patchTarget = await prepareGuardedPatch(backend, {
|
|
350
379
|
entityType: "node", bundle: type, id, existing, attributes, relationships: resolvedRelationships,
|
|
380
|
+
langcode,
|
|
351
381
|
});
|
|
352
382
|
if (dryRun) {
|
|
353
383
|
const preview = {
|
|
@@ -365,6 +395,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
|
|
|
365
395
|
else attributes.path = pathAttr;
|
|
366
396
|
const patched = await updateEntityGuarded(backend, {
|
|
367
397
|
entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
|
|
398
|
+
...(langcode ? { langcode } : {}),
|
|
368
399
|
...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
|
|
369
400
|
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
370
401
|
});
|
|
@@ -414,13 +445,14 @@ async function deleteNode({ site: siteName, type, id, dryRun = false }) {
|
|
|
414
445
|
export const definitions = [
|
|
415
446
|
{
|
|
416
447
|
name: "drupal_get_node",
|
|
417
|
-
description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes.",
|
|
448
|
+
description: "Fetch a single Drupal content node by UUID and content type. Returns title, body, status, path alias, and all attributes. Pass langcode to read a working translation draft via Sentinel (distinct from published English).",
|
|
418
449
|
inputSchema: {
|
|
419
450
|
type: "object", required: ["type", "id"],
|
|
420
451
|
properties: {
|
|
421
452
|
site: { type: "string", description: "Named site (omit for default)" },
|
|
422
453
|
type: { type: "string", description: "Content type machine name, e.g. 'article'" },
|
|
423
454
|
id: { type: "string", description: "Node UUID" },
|
|
455
|
+
langcode: { type: "string", description: "Target language (e.g. 'es') to read the unpublished working translation instead of the default language." },
|
|
424
456
|
},
|
|
425
457
|
},
|
|
426
458
|
},
|
|
@@ -477,7 +509,7 @@ export const definitions = [
|
|
|
477
509
|
},
|
|
478
510
|
{
|
|
479
511
|
name: "drupal_update_node",
|
|
480
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets 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.",
|
|
512
|
+
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.",
|
|
481
513
|
inputSchema: {
|
|
482
514
|
type: "object", required: ["type", "id"],
|
|
483
515
|
properties: {
|
|
@@ -490,6 +522,7 @@ export const definitions = [
|
|
|
490
522
|
format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
|
|
491
523
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
492
524
|
moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'." },
|
|
525
|
+
langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
|
|
493
526
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
|
|
494
527
|
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot." },
|
|
495
528
|
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
|
package/src/tools/revisions.js
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
import { getSiteConfig } from "../lib/config.js";
|
|
22
22
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
23
23
|
import {
|
|
24
|
-
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed,
|
|
24
|
+
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
|
|
25
25
|
} from "../lib/security.js";
|
|
26
|
+
import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
|
|
26
27
|
|
|
27
28
|
// Attributes that describe the entity's identity / revision bookkeeping / paths.
|
|
28
29
|
// These are read-only or server-managed and must NOT be replayed on a revert.
|
|
@@ -205,12 +206,41 @@ async function listRevisions({ site: siteName, type, id }) {
|
|
|
205
206
|
* @returns {Promise<?object>} The revision descriptor, or null if not found.
|
|
206
207
|
* @throws {SecurityError} If reading nodes/bundle is not permitted.
|
|
207
208
|
*/
|
|
208
|
-
async function getRevision({ site: siteName, type, id, version }) {
|
|
209
|
+
async function getRevision({ site: siteName, type, id, version, langcode }) {
|
|
209
210
|
const site = getSiteConfig(siteName);
|
|
210
211
|
const sec = resolveSecurityConfig(site);
|
|
211
212
|
assertReadAllowed(sec, "node", type);
|
|
212
213
|
const backend = await resolveBackend(site);
|
|
213
214
|
|
|
215
|
+
if (langcode) {
|
|
216
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
217
|
+
const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
|
|
218
|
+
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
219
|
+
if (!inventory.live?.vid || !inventory.working?.vid || !workingRow || workingRow.status !== false) {
|
|
220
|
+
throw new Error("No unpublished working translation is addressable for this revision.");
|
|
221
|
+
}
|
|
222
|
+
const entity = await readDraftTranslation(backend, {
|
|
223
|
+
entityType: "node", bundle: type, id, langcode: targetLang,
|
|
224
|
+
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
225
|
+
});
|
|
226
|
+
const redacted = redactCanonicalEntity(entity, sec, "node");
|
|
227
|
+
return {
|
|
228
|
+
entityType: "node",
|
|
229
|
+
bundle: type,
|
|
230
|
+
id,
|
|
231
|
+
vid: inventory.working.vid,
|
|
232
|
+
langcode: targetLang,
|
|
233
|
+
status: redacted.status,
|
|
234
|
+
attributes: {
|
|
235
|
+
title: redacted.title,
|
|
236
|
+
langcode: redacted.langcode,
|
|
237
|
+
status: redacted.status,
|
|
238
|
+
...redacted.fields,
|
|
239
|
+
},
|
|
240
|
+
relationships: redacted.relationships ?? {},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
214
244
|
const resource = await fetchRevisionResource(backend, "node", type, id, version);
|
|
215
245
|
if (!resource) return null;
|
|
216
246
|
|
|
@@ -314,13 +344,14 @@ export const definitions = [
|
|
|
314
344
|
{
|
|
315
345
|
name: "drupal_get_revision",
|
|
316
346
|
description:
|
|
317
|
-
"Fetch a single revision of a content node by version id or alias. `version` may be a numeric vid (e.g. 42), an explicit 'id:<vid>', or the relative aliases 'rel:latest-version' / 'rel:working-copy'. Read-only; attributes are redacted per security policy.",
|
|
347
|
+
"Fetch a single revision of a content node by version id or alias. `version` may be a numeric vid (e.g. 42), an explicit 'id:<vid>', or the relative aliases 'rel:latest-version' / 'rel:working-copy'. Pass langcode to read the unpublished working translation of that node (Sentinel). Read-only; attributes are redacted per security policy.",
|
|
318
348
|
inputSchema: {
|
|
319
349
|
type: "object", required: ["type", "id", "version"],
|
|
320
350
|
properties: {
|
|
321
351
|
site: { type: "string" },
|
|
322
352
|
type: { type: "string", description: "Content type machine name" },
|
|
323
353
|
id: { type: "string", description: "Node UUID" },
|
|
354
|
+
langcode: { type: "string", description: "Target language for the unpublished working translation (e.g. 'es')." },
|
|
324
355
|
version: {
|
|
325
356
|
type: ["string", "number"],
|
|
326
357
|
description: "Numeric vid, 'id:<vid>', 'rel:latest-version', or 'rel:working-copy'.",
|
|
@@ -1,57 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool group: Content translations (multilingual / content_translation).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* surface multilingual handling cleanly we expose:
|
|
4
|
+
* Core JSON:API does not add a translation by PATCHing `langcode` on the
|
|
5
|
+
* canonical entity — that mutates the default language. Governed node
|
|
6
|
+
* translations go through Sentinel's draft-translation surface:
|
|
8
7
|
*
|
|
9
|
-
* - drupal_list_translations —
|
|
10
|
-
* - drupal_create_translation —
|
|
11
|
-
* PATCHing the entity with the target `langcode` plus the supplied fields.
|
|
8
|
+
* - drupal_list_translations — inventory of live and working languages
|
|
9
|
+
* - drupal_create_translation — add a language as an unpublished forward draft
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* are rejected by Drupal and listing only ever reports the single language.
|
|
11
|
+
* Continuation of an existing unpublished translation is drupal_update_node
|
|
12
|
+
* with `langcode`. Reads of that draft are drupal_get_node / drupal_get_revision
|
|
13
|
+
* with `langcode`.
|
|
17
14
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* module therefore reports the langcode(s) it can observe on the returned
|
|
21
|
-
* resource. Enumerating ALL translations of an entity requires either the
|
|
22
|
-
* contrib "JSON:API Translation" module or the Drush bridge, neither of which
|
|
23
|
-
* is assumed here — see the `note` field on the list result.
|
|
24
|
-
*
|
|
25
|
-
* Reads are redacted per the site security policy; the create path is a
|
|
26
|
-
* governed write (asserts the "update" operation — a translation is a facet of
|
|
27
|
-
* an existing entity, not a new entity).
|
|
15
|
+
* Paragraph field-value translation is not supported on this path. Shared
|
|
16
|
+
* structure (references, files, aliases) stays on the source language.
|
|
28
17
|
*/
|
|
29
18
|
|
|
30
19
|
import { getSiteConfig } from "../lib/config.js";
|
|
31
20
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
32
21
|
import {
|
|
33
|
-
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed,
|
|
22
|
+
resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertPublishAllowed,
|
|
23
|
+
redactCanonicalEntity,
|
|
34
24
|
} from "../lib/security.js";
|
|
35
25
|
import { validateUuid, validateMachineName } from "../lib/validate.js";
|
|
26
|
+
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
27
|
+
import { loadWorkingCopy } from "../lib/patch-preflight.js";
|
|
28
|
+
import { entityRevisionId } from "../lib/write-revision.js";
|
|
29
|
+
import {
|
|
30
|
+
assertDraftLangcode,
|
|
31
|
+
createTranslationDraft,
|
|
32
|
+
readTranslationInventory,
|
|
33
|
+
} from "../lib/draft-write.js";
|
|
36
34
|
|
|
37
35
|
const LIST_NOTE =
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
36
|
+
"Live languages are those on the default revision. Working languages are the " +
|
|
37
|
+
"unpublished forward revision when the principal can view it. Core JSON:API " +
|
|
38
|
+
"alone cannot enumerate translations; this inventory requires Sentinel's " +
|
|
39
|
+
"mcp-translations endpoint.";
|
|
40
|
+
|
|
41
|
+
const FALLBACK_NOTE =
|
|
42
|
+
"Sentinel's translation inventory is unavailable. Core JSON:API served one " +
|
|
43
|
+
"language for this resource; it does not prove other translations are absent.";
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
* List
|
|
46
|
-
*
|
|
47
|
-
* Reads the entity through the backend's validated rawQuery (so path segments
|
|
48
|
-
* are checked and content_moderation handling is reused) and reports its
|
|
49
|
-
* `langcode`. The entity type defaults to "node"; pass `entityType` for others.
|
|
46
|
+
* List live and working translation langcodes for an entity.
|
|
50
47
|
*
|
|
51
48
|
* @param {object} args - { site?, entityType?, type, id }.
|
|
52
|
-
* `type` is the bundle machine name; `entityType` defaults to "node".
|
|
53
49
|
* @returns {Promise<object|null>} A translation summary, or null if not found.
|
|
54
|
-
* @throws {SecurityError} If reading the type/bundle is not permitted.
|
|
55
50
|
*/
|
|
56
51
|
async function listTranslations({ site: siteName, entityType = "node", type, id }) {
|
|
57
52
|
validateMachineName(entityType, "entityType");
|
|
@@ -63,6 +58,36 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
|
|
|
63
58
|
assertReadAllowed(sec, entityType, type);
|
|
64
59
|
|
|
65
60
|
const backend = await resolveBackend(site);
|
|
61
|
+
if (entityType === "node" && typeof backend.rawQuery === "function") {
|
|
62
|
+
try {
|
|
63
|
+
const meta = await readTranslationInventory(backend, { entityType, bundle: type, id });
|
|
64
|
+
const liveLangs = (meta.live?.translations ?? []).map((row) => row.langcode);
|
|
65
|
+
const workingLangs = (meta.working?.translations ?? []).map((row) => row.langcode);
|
|
66
|
+
const langcodes = [...new Set([...liveLangs, ...workingLangs])];
|
|
67
|
+
return {
|
|
68
|
+
id,
|
|
69
|
+
entityType,
|
|
70
|
+
bundle: type,
|
|
71
|
+
defaultLangcode: meta.defaultLangcode ?? liveLangs[0] ?? null,
|
|
72
|
+
langcodes,
|
|
73
|
+
live: meta.live ?? null,
|
|
74
|
+
working: meta.working ?? null,
|
|
75
|
+
translations: (meta.working?.translations ?? meta.live?.translations ?? []).map((row) => ({
|
|
76
|
+
langcode: row.langcode,
|
|
77
|
+
default: Boolean(row.default),
|
|
78
|
+
status: row.status,
|
|
79
|
+
title: row.title,
|
|
80
|
+
moderation_state: row.moderation_state,
|
|
81
|
+
})),
|
|
82
|
+
note: LIST_NOTE,
|
|
83
|
+
};
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (!/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
const res = await backend.rawQuery({ path: `/jsonapi/${entityType}/${type}/${id}` });
|
|
67
92
|
const data = res?.data;
|
|
68
93
|
if (!data) return null;
|
|
@@ -78,69 +103,89 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
|
|
|
78
103
|
defaultLangcode,
|
|
79
104
|
langcodes,
|
|
80
105
|
translations,
|
|
81
|
-
note:
|
|
106
|
+
note: FALLBACK_NOTE,
|
|
82
107
|
};
|
|
83
108
|
}
|
|
84
109
|
|
|
85
110
|
/**
|
|
86
|
-
* Create
|
|
111
|
+
* Create a translation as an unpublished non-default draft revision.
|
|
87
112
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* content_translation enabled and the bundle marked translatable, Drupal stores
|
|
91
|
-
* the supplied fields against the requested language. If the bundle is not
|
|
92
|
-
* translatable, Drupal rejects the write and the error surfaces to the caller.
|
|
93
|
-
*
|
|
94
|
-
* The result is redacted per the site policy.
|
|
113
|
+
* Does not PATCH canonical langcode. Requires Sentinel's translation endpoint.
|
|
114
|
+
* An existing translation is a conflict. English live fields stay unchanged.
|
|
95
115
|
*
|
|
96
116
|
* @param {object} args - { site?, entityType?, type, id, langcode, attributes? }.
|
|
97
|
-
*
|
|
98
|
-
* target language (e.g. "de"); `attributes` are the translated field values.
|
|
99
|
-
* @returns {Promise<object>} The updated/redacted entity descriptor.
|
|
100
|
-
* @throws {SecurityError} If writing the type/bundle is not permitted.
|
|
101
|
-
* @throws {Error} If langcode/id/type are invalid, or Drupal rejects the write.
|
|
117
|
+
* @returns {Promise<object>} The created translation, redacted.
|
|
102
118
|
*/
|
|
103
|
-
async function createTranslation({
|
|
119
|
+
async function createTranslation({
|
|
120
|
+
site: siteName, entityType = "node", type, id, langcode, attributes = {}, dryRun = false,
|
|
121
|
+
}) {
|
|
104
122
|
validateMachineName(entityType, "entityType");
|
|
105
123
|
validateMachineName(type, "type");
|
|
106
124
|
validateUuid(id);
|
|
107
|
-
|
|
108
|
-
// variant; validate it as a machine-name-like token (e.g. "en", "pt_br",
|
|
109
|
-
// "zh_hans") to block injection / malformed values.
|
|
110
|
-
validateMachineName(langcode, "langcode");
|
|
125
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
111
126
|
|
|
112
127
|
const site = getSiteConfig(siteName);
|
|
113
128
|
const sec = resolveSecurityConfig(site);
|
|
114
|
-
// A translation is a facet of an existing entity → "update", not "create".
|
|
115
129
|
assertWriteAllowed(sec, "update", entityType, type);
|
|
116
130
|
|
|
131
|
+
if (entityType !== "node") {
|
|
132
|
+
throw new Error("Governed translation create is implemented for nodes. Other entity types are not addressed by the draft-translation contract.");
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
const backend = await resolveBackend(site);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
136
|
+
const existing = await backend.getEntity({ entityType, bundle: type, id });
|
|
137
|
+
if (!existing) {
|
|
138
|
+
throw new Error("The entity was not found.");
|
|
139
|
+
}
|
|
140
|
+
const liveVid = entityRevisionId(existing);
|
|
141
|
+
const workingCopy = await loadWorkingCopy(backend, { entityType, bundle: type, id });
|
|
142
|
+
const workingVid = entityRevisionId(workingCopy);
|
|
143
|
+
const sameWorking = workingVid !== null && liveVid !== null && String(workingVid) === String(liveVid);
|
|
144
|
+
const draftRevision = {
|
|
145
|
+
liveVid,
|
|
146
|
+
workingVid: workingCopy && !sameWorking ? workingVid : undefined,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const safeAttributes = { ...attributes };
|
|
150
|
+
delete safeAttributes.langcode;
|
|
151
|
+
if (safeAttributes.status === undefined && safeAttributes.moderation_state === undefined) {
|
|
152
|
+
safeAttributes.moderation_state = "draft";
|
|
153
|
+
}
|
|
154
|
+
const drafted = await applySafeDraftDefault({
|
|
155
|
+
backend, entityType, bundle: type, id, attributes: safeAttributes, existingEntity: existing,
|
|
123
156
|
});
|
|
124
|
-
|
|
125
|
-
}
|
|
157
|
+
assertPublishAllowed(sec, drafted);
|
|
126
158
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
159
|
+
if (dryRun) {
|
|
160
|
+
await createTranslationDraft(backend, {
|
|
161
|
+
entityType, bundle: type, id, langcode: targetLang, attributes: drafted, draftRevision,
|
|
162
|
+
}, true);
|
|
163
|
+
return {
|
|
164
|
+
dryRun: true, operation: "create_translation", entityType, bundle: type, id,
|
|
165
|
+
langcode: targetLang, attributes: drafted,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const created = await createTranslationDraft(backend, {
|
|
170
|
+
entityType, bundle: type, id, langcode: targetLang, attributes: drafted, draftRevision,
|
|
171
|
+
});
|
|
172
|
+
return redactCanonicalEntity(created, sec, entityType);
|
|
173
|
+
}
|
|
130
174
|
|
|
131
175
|
export const definitions = [
|
|
132
176
|
{
|
|
133
177
|
name: "drupal_list_translations",
|
|
134
178
|
description:
|
|
135
|
-
"List
|
|
136
|
-
"
|
|
137
|
-
"
|
|
179
|
+
"List live and working translation langcodes for a Drupal node. Uses Sentinel's " +
|
|
180
|
+
"translation inventory when available (live default revision vs unpublished working " +
|
|
181
|
+
"draft). Core JSON:API alone serves one language and cannot prove others are absent. " +
|
|
182
|
+
"Defaults to node.",
|
|
138
183
|
inputSchema: {
|
|
139
184
|
type: "object", required: ["type", "id"],
|
|
140
185
|
properties: {
|
|
141
186
|
site: { type: "string", description: "Named site (omit for default)" },
|
|
142
187
|
entityType: { type: "string", description: "Entity type machine name. Default: 'node'." },
|
|
143
|
-
type: { type: "string", description: "Bundle machine name, e.g. '
|
|
188
|
+
type: { type: "string", description: "Bundle machine name, e.g. 'basic_page'" },
|
|
144
189
|
id: { type: "string", description: "Entity UUID" },
|
|
145
190
|
},
|
|
146
191
|
},
|
|
@@ -148,28 +193,28 @@ export const definitions = [
|
|
|
148
193
|
{
|
|
149
194
|
name: "drupal_create_translation",
|
|
150
195
|
description:
|
|
151
|
-
"Create
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
196
|
+
"Create a translation as an unpublished non-default draft revision (governed write). " +
|
|
197
|
+
"Adds the target language beside the default language; it does not PATCH langcode on " +
|
|
198
|
+
"the canonical entity. English live title, body, status, alias, and default revision " +
|
|
199
|
+
"stay unchanged. An existing translation is a conflict, not an overwrite. Continue the " +
|
|
200
|
+
"draft with drupal_update_node and langcode. Requires Sentinel's draft-translation " +
|
|
201
|
+
"endpoint and a translatable bundle. Paragraph field values are not translated on this " +
|
|
202
|
+
"path. Defaults to node. Publication stays denied for content-tier callers.",
|
|
155
203
|
inputSchema: {
|
|
156
204
|
type: "object", required: ["type", "id", "langcode"],
|
|
157
205
|
properties: {
|
|
158
206
|
site: { type: "string" },
|
|
159
207
|
entityType: { type: "string", description: "Entity type machine name. Default: 'node'." },
|
|
160
|
-
type: { type: "string", description: "Bundle machine name, e.g. '
|
|
208
|
+
type: { type: "string", description: "Bundle machine name, e.g. 'basic_page'" },
|
|
161
209
|
id: { type: "string", description: "Entity UUID" },
|
|
162
|
-
langcode: { type: "string", description: "Target language code, e.g. '
|
|
210
|
+
langcode: { type: "string", description: "Target language code, e.g. 'es', 'de', 'pt-br'" },
|
|
163
211
|
attributes: { type: "object", description: "Translated field values keyed by Drupal machine name" },
|
|
212
|
+
dryRun: { type: "boolean", description: "Validate without saving" },
|
|
164
213
|
},
|
|
165
214
|
},
|
|
166
215
|
},
|
|
167
216
|
];
|
|
168
217
|
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
// Handler map
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
|
|
173
218
|
export const handlers = {
|
|
174
219
|
drupal_list_translations: listTranslations,
|
|
175
220
|
drupal_create_translation: createTranslation,
|