drupal-mcp-connector 2.14.0 → 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.
@@ -1,22 +1,23 @@
1
1
  ---
2
- description: "Create or replace a translation of a Drupal entity for a target language (governed write). Sets the given langcode plus the supplied translated field values. Requires the content_translation module enabled and the bundle configured as translatable; otherwise Drupal rejects the write. Defaults to node."
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 or replace a translation of a Drupal entity for a target language (governed write). Sets the given langcode plus the supplied translated field values. Requires the content_translation module enabled and the bundle configured as translatable; otherwise Drupal rejects the write. Defaults to node.
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. 'article'
13
+ - `type` (string): Bundle machine name, e.g. 'basic_page'
14
14
  - `id` (string): Entity UUID
15
- - `langcode` (string): Target language code, e.g. 'de', 'fr', 'pt_br'
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 the translation langcode(s) for a Drupal entity (multilingual / content_translation). Reports the language(s) observable on the resource. Core JSON:API serves one language per resource and does not enumerate all translations — see the returned note. Defaults to node."
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 the translation langcode(s) for a Drupal entity (multilingual / content_translation). Reports the language(s) observable on the resource. Core JSON:API serves one language per resource and does not enumerate all translations — see the returned note. Defaults to node.
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. 'article'
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,37 @@ 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
+
30
+ ## [2.14.1] - 2026-09-09
31
+
32
+ ### Changed
33
+ - README, CONTRIBUTING, and the package.json author URL now link
34
+ Jeremy Michael Cerda to the drupal.org profile
35
+ (`https://www.drupal.org/u/jmcerda`).
36
+ - README and docs now report 120 tools and 125 prompts (one per tool
37
+ plus 5 workflow prompts), matching the shipped surface. The tools
38
+ reference documents `drupal_create_redirect`,
39
+ `drupal_update_redirect`, and `drupal_update_menu_link`.
40
+
10
41
  ## [2.14.0] - 2026-09-06
11
42
 
12
43
  ### Added
@@ -1470,6 +1501,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1470
1501
  - User tools gained explicit PII-access assertions.
1471
1502
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1472
1503
 
1504
+ [2.14.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.2
1505
+ [2.14.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.1
1473
1506
  [2.14.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.0
1474
1507
  [2.13.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.1
1475
1508
  [2.13.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.13.0
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![Drupal](https://img.shields.io/badge/drupal-10%20%7C%2011-blue)](https://drupal.org)
8
8
  [![MCP](https://img.shields.io/badge/MCP-2026--07--28-purple)](https://modelcontextprotocol.io)
9
9
 
10
- Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
10
+ Built by **[Jeremy Michael Cerda](https://www.drupal.org/u/jmcerda)** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
12
  **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to the current release (2.6.1 first fixed this), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
13
13
 
@@ -63,7 +63,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
63
63
 
64
64
  ## Features
65
65
 
66
- ### 119 Tools Across 26 Modules
66
+ ### 120 Tools Across 26 Modules
67
67
 
68
68
  | Module | Tools |
69
69
  |--------|-------|
@@ -301,7 +301,7 @@ an operator channel: keep the agent's credentials off it, and pin
301
301
  | [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
302
302
  | [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
303
303
  | [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
304
- | [Tools Reference](docs/tools-reference.md) | Full reference for all 119 tools |
304
+ | [Tools Reference](docs/tools-reference.md) | Full reference for all 120 tools |
305
305
  | [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
306
306
  | [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
307
307
  | [Verification](docs/verification.md) | Prove a secure install: static + live checks, negative probes, evidence artefact, managed residuals |
@@ -324,4 +324,4 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md). Please do not open a publ
324
324
 
325
325
  ## License
326
326
 
327
- [MIT](LICENSE) © 2026 Jeremy Michael Cerda and [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty)
327
+ [MIT](LICENSE) © 2026 [Jeremy Michael Cerda](https://www.drupal.org/u/jmcerda) and [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.14.0",
3
+ "version": "2.14.2",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -42,7 +42,7 @@
42
42
  "url": "https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues"
43
43
  },
44
44
  "license": "MIT",
45
- "author": "Jeremy Michael Cerda <opensource@wilkesliberty.com> (https://wilkesliberty.com)",
45
+ "author": "Jeremy Michael Cerda <opensource@wilkesliberty.com> (https://www.drupal.org/u/jmcerda)",
46
46
  "contributors": [
47
47
  "Wilkes & Liberty, LLC <opensource@wilkesliberty.com> (https://wilkesliberty.com)"
48
48
  ],
@@ -79,7 +79,7 @@
79
79
  "eslint-plugin-n": "^18.1.0",
80
80
  "eslint-plugin-security": "^4.0.0",
81
81
  "globals": "^17.6.0",
82
- "vitest": "^4.1.8"
82
+ "vitest": "^5.0.0"
83
83
  },
84
84
  "overrides": {
85
85
  "@hono/node-server": "^2.0.5",
@@ -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 working = String(draftRevision?.workingVid ?? "");
18
- if (entityType !== "node" || !/^[1-9]\d*$/.test(live)
19
- || !/^[1-9]\d*$/.test(working) || live === working) {
20
- throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
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 (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
23
- throw new Error("This backend does not support governed draft continuation.");
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 data = { type: `${entityType}--${bundle}`, id, attributes };
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: `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}/mcp-draft`,
146
+ path: `${base}/mcp-draft/translations`,
31
147
  options: {
32
- method: "PATCH",
148
+ method: "POST",
33
149
  headers: {
34
- "If-Match": `"${live}:${working}"`,
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
- if (/Drupal (404|405)\b/.test(String(error?.message))) {
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
- || String(result.meta.live) !== live || String(result.meta.working) !== working) {
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("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
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, {
@@ -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." },
@@ -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
- const entity = await backend.getEntity({ entityType: "node", bundle: type, id });
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." },
@@ -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
- * Drupal core JSON:API does not model translations as standalone resources
5
- * a translatable entity carries a `langcode` attribute and Drupal serves the
6
- * negotiated/default-language variant of a resource at its canonical path. To
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 — read the entity and report its langcode(s).
10
- * - drupal_create_translation — governed write: set/replace a translation by
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
- * Requirements (server side):
14
- * - The Drupal `content_translation` module must be enabled and the target
15
- * entity type/bundle configured as translatable, otherwise create attempts
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
- * Limitation: core JSON:API exposes the resource in one language at a time and
19
- * does not enumerate every available translation as a distinct resource. This
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, redactCanonicalEntity,
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
- "Core JSON:API serves one language per resource and does not enumerate every " +
39
- "translation. langcodes reflects only the language(s) observable on the fetched " +
40
- "resource. Full enumeration requires the JSON:API Translation contrib module or " +
41
- "the Drush bridge. Creating translations requires content_translation enabled and " +
42
- "the bundle configured as translatable.";
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 the translation langcode(s) observable for an entity.
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: LIST_NOTE,
106
+ note: FALLBACK_NOTE,
82
107
  };
83
108
  }
84
109
 
85
110
  /**
86
- * Create (or replace) a translation of an entity for a target language.
111
+ * Create a translation as an unpublished non-default draft revision.
87
112
  *
88
- * Governed write. Implemented as a PATCH (via the backend's updateEntity) that
89
- * sets the target `langcode` alongside the supplied attributes. With
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
- * `type` is the bundle; `entityType` defaults to "node"; `langcode` is the
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({ site: siteName, entityType = "node", type, id, langcode, attributes = {} }) {
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
- // langcode is interpolated into the JSON:API payload and selects the language
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 updated = await backend.updateEntity({
119
- entityType,
120
- bundle: type,
121
- id,
122
- attributes: { ...attributes, langcode },
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
- return redactCanonicalEntity(updated, sec, entityType);
125
- }
157
+ assertPublishAllowed(sec, drafted);
126
158
 
127
- // ---------------------------------------------------------------------------
128
- // Tool definitions
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 the translation langcode(s) for a Drupal entity (multilingual / content_translation). " +
136
- "Reports the language(s) observable on the resource. Core JSON:API serves one language per " +
137
- "resource and does not enumerate all translations see the returned note. Defaults to node.",
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. 'article'" },
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 or replace a translation of a Drupal entity for a target language (governed write). " +
152
- "Sets the given langcode plus the supplied translated field values. Requires the content_translation " +
153
- "module enabled and the bundle configured as translatable; otherwise Drupal rejects the write. " +
154
- "Defaults to node.",
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. 'article'" },
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. 'de', 'fr', 'pt_br'" },
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,