drupal-mcp-connector 2.22.0 → 2.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/commands/drupal-create-translation.md +5 -4
- package/.agents/commands/drupal-update-media.md +2 -2
- package/.agents/commands/drupal-update-node.md +2 -2
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/lib/patch-preflight.js +113 -8
- package/src/lib/sentinel-draft.js +127 -10
- package/src/tools/media.js +1 -1
- package/src/tools/nodes.js +1 -1
- package/src/tools/translations.js +64 -11
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers."
|
|
3
|
-
argument-hint: "<type> <id> <langcode> [site] [entityType] [attributes] [relationships] [revisionId] [dryRun]"
|
|
2
|
+
description: "Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. Pass revise: true to open an unpublished draft over a language that is already published on the live revision and has no working copy (Sentinel X-MCP-Draft-Mode: revise). That requires MCP Sentinel 2.24.0 or later; an older host is refused before any write and the message names that version. dryRun uses the same translations endpoint and Sentinel's non-saving preflight. A working copy is refused. Paragraph revise is not supported. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers."
|
|
3
|
+
argument-hint: "<type> <id> <langcode> [site] [entityType] [attributes] [relationships] [revisionId] [dryRun] [revise]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_create_translation`.
|
|
7
7
|
|
|
8
|
-
Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. Publication stays denied for content-tier callers.
|
|
8
|
+
Create a translation as an unpublished non-default draft (governed write). Adds the target language beside the default language; it does not PATCH langcode on the canonical entity. When an English working draft already exists, both live and working revision IDs are sent (If-Match) so Sentinel will add the language on that draft (#282). English live title, body, status, alias, default revision, and paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. Pass revise: true to open an unpublished draft over a language that is already published on the live revision and has no working copy (Sentinel X-MCP-Draft-Mode: revise). That requires MCP Sentinel 2.24.0 or later; an older host is refused before any write and the message names that version. dryRun uses the same translations endpoint and Sentinel's non-saving preflight. A working copy is refused. Paragraph revise is not supported. The response includes `_revisions.live` / `_revisions.working` when known. Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. Continue a node draft with drupal_update_node and langcode; a paragraph with drupal_update_paragraph and langcode; media with drupal_update_media and langcode. Image alt is a relationship (same file UUID, meta.alt) — on media that is field_media_image, not a node image field. For paragraphs pass revisionId as the host pin. Requires Sentinel's draft-translation endpoint. 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
|
|
|
@@ -20,6 +20,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
20
20
|
- `attributes` (object (pass as JSON)): Translated field values keyed by Drupal machine name
|
|
21
21
|
- `relationships` (object (pass as JSON)): JSON:API relationships. Use for image alt (same file UUID, meta.alt).
|
|
22
22
|
- `revisionId` (string): Paragraph revision id the host already pins. Required for Home-shaped non-default pins.
|
|
23
|
-
- `dryRun` (boolean (true/false)): Validate without saving. Sentinel's non-saving translation preflight receives the real fields, applies them through field access and validates the entity. The result's `checks` block says what was checked.
|
|
23
|
+
- `dryRun` (boolean (true/false)): Validate without saving. Sentinel's non-saving translation preflight receives the real fields, applies them through field access and validates the entity. The result's `checks` block says what was checked. With revise: true the preflight is the same translations endpoint.
|
|
24
|
+
- `revise` (boolean (true/false)): Open an unpublished draft over a translation that is already published on the live default revision and has no working copy. Requires MCP Sentinel 2.24.0 or later. Omit it to create a language that does not exist yet.
|
|
24
25
|
|
|
25
26
|
If a required parameter is missing, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first."
|
|
2
|
+
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. A published translation with no working copy is opened with drupal_create_translation and revise: true, not with this tool."
|
|
3
3
|
argument-hint: "<type> <id> [site] [name] [status] [fields] [langcode]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_update_media`.
|
|
7
7
|
|
|
8
|
-
Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first.
|
|
8
|
+
Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. A published translation with no working copy is opened with drupal_create_translation and revise: true, not with this tool.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation.
|
|
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. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is already published and has no working copy is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language with no working copy is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran."
|
|
3
3
|
argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [langcode] [fields] [relationships] [dryRun] [returning]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_update_node`.
|
|
7
7
|
|
|
8
|
-
Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation.
|
|
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. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is already published and has no working copy is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language with no working copy is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.23.1] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Default-language draft on a multilingual node (#379).** `drupal_update_node`
|
|
14
|
+
and the other update tools without `langcode` no longer stop at Sentinel's
|
|
15
|
+
409 when the working revision holds more than one language. When the default
|
|
16
|
+
language is the unpublished draft in that revision, the connector names it in
|
|
17
|
+
`X-MCP-Draft-Langcode`, on the preflight and on the write. It reads the
|
|
18
|
+
translation inventory only after Sentinel asks for the language, so other
|
|
19
|
+
writes make no extra request. A translation-only draft over published default
|
|
20
|
+
copy still needs an explicit `langcode`. When an older Sentinel refuses a
|
|
21
|
+
shared-field or paragraph change as a translation write, the message names
|
|
22
|
+
MCP Sentinel 2.24.2.
|
|
23
|
+
|
|
24
|
+
## [2.23.0] - 2026-09-23
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **Revise a published translation (#376).** `drupal_create_translation` accepts
|
|
28
|
+
`revise: true` to open an unpublished draft over a language that is already
|
|
29
|
+
published on the live revision and has no working copy. The call uses
|
|
30
|
+
Sentinel's translations endpoint and `X-MCP-Draft-Mode: revise`, including
|
|
31
|
+
on `dryRun`. A host that does not advertise `revise_published_translation`
|
|
32
|
+
is refused before any write; the message names MCP Sentinel 2.24.0.
|
|
33
|
+
`drupal_update_node` and `drupal_update_media` with `langcode` name that
|
|
34
|
+
flag instead of sending the caller to a create that is certain to 409.
|
|
35
|
+
A plain create that hits the existing-language 409 says to pass `revise: true`
|
|
36
|
+
when the language is published and there is no working copy, and to continue
|
|
37
|
+
with the update tool when an unpublished working translation already exists.
|
|
38
|
+
|
|
10
39
|
## [2.22.0] - 2026-09-20
|
|
11
40
|
|
|
12
41
|
### Added
|
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
|
|
|
88
88
|
| **Fields** | Describe a bundle's fields (type/required/cardinality, best-effort) |
|
|
89
89
|
| **References** | Resolve a human name/title to an entity UUID for relationship fields |
|
|
90
90
|
| **Bulk** | Bulk create/update with per-item partial-failure reporting |
|
|
91
|
-
| **Translations** | List
|
|
91
|
+
| **Translations** | List, create, and revise published entity translations (`revise: true` on `drupal_create_translation`, MCP Sentinel 2.24.0+) |
|
|
92
92
|
| **Paragraphs** | Create/update/get Paragraph components for embedding in host fields |
|
|
93
93
|
| **Structure** | Menu links (list/create/update, incl. `parent` + `enabled`) + custom blocks (list/create) |
|
|
94
94
|
| **Redirects** | Create active URL redirects (301/302) + update/repoint existing redirects (Redirect module) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.23.1",
|
|
4
4
|
"description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -25,7 +25,13 @@
|
|
|
25
25
|
import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
|
|
26
26
|
import { entityRevisionId } from "./write-revision.js";
|
|
27
27
|
import { httpStatusOf } from "./error-status.js";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
writeDraft,
|
|
30
|
+
readNodeDraftInventory,
|
|
31
|
+
assertInventoryDraftLanguage,
|
|
32
|
+
inferDefaultDraftLangcode,
|
|
33
|
+
MIN_SENTINEL_DEFAULT_LANGUAGE_DRAFT_VERSION,
|
|
34
|
+
} from "./sentinel-draft.js";
|
|
29
35
|
import { PREFLIGHT_NONE, PREFLIGHT_CORE_GUARD, PREFLIGHT_SENTINEL_DRAFT } from "./dry-run-checks.js";
|
|
30
36
|
|
|
31
37
|
/** Stable error code for a core working-copy / not-latest-revision block. */
|
|
@@ -261,6 +267,11 @@ export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundl
|
|
|
261
267
|
return { resourceVersion: `id:${workingVid}`, workingCopy: null,
|
|
262
268
|
liveVid, workingVid, inventory };
|
|
263
269
|
}
|
|
270
|
+
if (!workingCopy) {
|
|
271
|
+
return {
|
|
272
|
+
resourceVersion: undefined, workingCopy: null, liveVid, workingVid: null, inventory,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
264
275
|
}
|
|
265
276
|
}
|
|
266
277
|
if (!workingCopy) {
|
|
@@ -425,14 +436,36 @@ export async function prepareGuardedPatch(backend, {
|
|
|
425
436
|
throw new StaleCopyError();
|
|
426
437
|
}
|
|
427
438
|
}
|
|
428
|
-
|
|
439
|
+
// An inventory already in hand names the default-language draft for free.
|
|
440
|
+
let inferredLangcode = langcode ? undefined : inferDefaultDraftLangcode(target.inventory);
|
|
441
|
+
if (!langcode && !inferredLangcode && target.inventory) {
|
|
442
|
+
assertInventoryDraftLanguage(target.inventory, langcode);
|
|
443
|
+
}
|
|
429
444
|
if (langcode) {
|
|
430
|
-
|
|
445
|
+
const noWorking = !target.workingVid || !target.liveVid
|
|
446
|
+
|| String(target.workingVid) === String(target.liveVid);
|
|
447
|
+
if (noWorking) {
|
|
448
|
+
const liveRow = (target.inventory?.live?.translations ?? [])
|
|
449
|
+
.find((row) => row?.langcode === langcode);
|
|
450
|
+
if (liveRow && liveRow.status === true) {
|
|
451
|
+
if (langcode === target.inventory?.defaultLangcode || liveRow.default === true) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
"The default language is not opened with drupal_create_translation. " +
|
|
454
|
+
"Omit langcode to draft it. A canonical langcode PATCH is not attempted."
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
throw new Error(
|
|
458
|
+
"This language is published on the live revision and has no working copy. " +
|
|
459
|
+
"Revise it with drupal_create_translation and revise: true. " +
|
|
460
|
+
"A canonical langcode PATCH is not attempted."
|
|
461
|
+
);
|
|
462
|
+
}
|
|
431
463
|
throw new Error(
|
|
432
464
|
"No unpublished working translation for this language. " +
|
|
433
465
|
"Create it with drupal_create_translation first; a canonical langcode PATCH is not attempted."
|
|
434
466
|
);
|
|
435
467
|
}
|
|
468
|
+
if (target.inventory) assertInventoryDraftLanguage(target.inventory, langcode);
|
|
436
469
|
target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
|
|
437
470
|
try {
|
|
438
471
|
await writeDraft(backend, {
|
|
@@ -446,14 +479,32 @@ export async function prepareGuardedPatch(backend, {
|
|
|
446
479
|
return target;
|
|
447
480
|
}
|
|
448
481
|
if (target.resourceVersion) {
|
|
449
|
-
|
|
482
|
+
const draftRevisionFor = (lang) => ({
|
|
483
|
+
liveVid: target.liveVid,
|
|
484
|
+
workingVid: target.workingVid,
|
|
485
|
+
...(lang ? { langcode: lang } : {}),
|
|
486
|
+
});
|
|
487
|
+
const preflightDraft = (lang) => writeDraft(backend, {
|
|
488
|
+
entityType, bundle, id, attributes, relationships, draftRevision: draftRevisionFor(lang),
|
|
489
|
+
}, true);
|
|
450
490
|
try {
|
|
451
|
-
await
|
|
452
|
-
entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
|
|
453
|
-
}, true);
|
|
491
|
+
await preflightDraft(inferredLangcode);
|
|
454
492
|
} catch (err) {
|
|
455
|
-
|
|
493
|
+
// The working-copy alias found the draft without Sentinel's inventory.
|
|
494
|
+
// When Sentinel asks for the language of a multilingual revision, read
|
|
495
|
+
// the inventory once and name the default-language draft (#379).
|
|
496
|
+
const retryLang = !inferredLangcode && isMultilingualLanguageRefusal(err)
|
|
497
|
+
? await discoverDefaultDraftLangcode(backend, { entityType, bundle, id }, target)
|
|
498
|
+
: undefined;
|
|
499
|
+
if (!retryLang) throw explainDefaultLanguageRefusal(rewriteStaleCopyError(err), inferredLangcode);
|
|
500
|
+
inferredLangcode = retryLang;
|
|
501
|
+
try {
|
|
502
|
+
await preflightDraft(inferredLangcode);
|
|
503
|
+
} catch (retryErr) {
|
|
504
|
+
throw explainDefaultLanguageRefusal(rewriteStaleCopyError(retryErr), inferredLangcode);
|
|
505
|
+
}
|
|
456
506
|
}
|
|
507
|
+
target.draftRevision = draftRevisionFor(inferredLangcode);
|
|
457
508
|
target.preflight = PREFLIGHT_SENTINEL_DRAFT;
|
|
458
509
|
return target;
|
|
459
510
|
}
|
|
@@ -469,6 +520,60 @@ export async function prepareGuardedPatch(backend, {
|
|
|
469
520
|
return target;
|
|
470
521
|
}
|
|
471
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Sentinel's 409 for a multilingual working revision sent without
|
|
525
|
+
* X-MCP-Draft-Langcode (wording before and after 2.24.2).
|
|
526
|
+
* @param {*} err
|
|
527
|
+
* @returns {boolean}
|
|
528
|
+
*/
|
|
529
|
+
function isMultilingualLanguageRefusal(err) {
|
|
530
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
531
|
+
return /X-MCP-Draft-Langcode/.test(message)
|
|
532
|
+
&& /(Translated draft continuation requires|more than one language)/.test(message);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Read Sentinel's inventory and return the default language when it is the
|
|
537
|
+
* unpublished draft of a multilingual working revision.
|
|
538
|
+
* @param {object} backend
|
|
539
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
540
|
+
* @param {{liveVid: *, workingVid: *}} target The revision pair the write targets.
|
|
541
|
+
* @returns {Promise<string|undefined>}
|
|
542
|
+
* @throws {WorkingCopyStaleError} When the inventory names a different working revision.
|
|
543
|
+
*/
|
|
544
|
+
async function discoverDefaultDraftLangcode(backend, ref, { liveVid, workingVid }) {
|
|
545
|
+
// A missing endpoint is null; permission, transport and malformed-response
|
|
546
|
+
// failures surface as themselves rather than as the original 409.
|
|
547
|
+
const inventory = await readNodeDraftInventory(backend, ref);
|
|
548
|
+
if (!inventory) return undefined;
|
|
549
|
+
const moved = !inventory.working
|
|
550
|
+
|| (workingVid !== null && workingVid !== undefined && String(inventory.working.vid) !== String(workingVid))
|
|
551
|
+
|| (liveVid !== null && liveVid !== undefined && String(inventory.live.vid) !== String(liveVid));
|
|
552
|
+
if (moved) {
|
|
553
|
+
throw new WorkingCopyStaleError(new Error("Sentinel's live or working revision changed during discovery."));
|
|
554
|
+
}
|
|
555
|
+
return inferDefaultDraftLangcode(inventory);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Sentinel before 2.24.2 treats a default-language header as a translation
|
|
560
|
+
* write and refuses shared-field and paragraph changes. Say so, instead of
|
|
561
|
+
* surfacing a translation error for a default-language edit (#379).
|
|
562
|
+
* @param {*} err
|
|
563
|
+
* @param {string|undefined} inferredLangcode
|
|
564
|
+
* @returns {*}
|
|
565
|
+
*/
|
|
566
|
+
function explainDefaultLanguageRefusal(err, inferredLangcode) {
|
|
567
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
568
|
+
const translationRule = /on a translation draft|A translation cannot replace|omit langcode to change the shared workflow state/;
|
|
569
|
+
if (!inferredLangcode || !translationRule.test(message)) return err;
|
|
570
|
+
return new Error(
|
|
571
|
+
`${message} This edit targets the default language (${inferredLangcode}) of a multilingual draft. ` +
|
|
572
|
+
"Changing shared fields or paragraph structure there needs MCP Sentinel " +
|
|
573
|
+
`${MIN_SENTINEL_DEFAULT_LANGUAGE_DRAFT_VERSION} or later. No write was attempted.`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
472
577
|
/**
|
|
473
578
|
* `backend.updateEntity` with the core working-copy 400 rewritten.
|
|
474
579
|
* A working-copy-targeted 400 is stale/concurrent (#166). A canonical 400
|
|
@@ -21,6 +21,38 @@ const MISSING_TRANSLATION_ENDPOINT =
|
|
|
21
21
|
const MISSING_DRAFT_RE = /does not provide Sentinel's governed draft endpoint/;
|
|
22
22
|
const MISSING_TRANSLATION_RE = /does not provide Sentinel's governed draft-translation endpoint/;
|
|
23
23
|
|
|
24
|
+
/** Inventory operation that opens a draft over a published translation. */
|
|
25
|
+
export const REVISE_PUBLISHED_TRANSLATION = "revise_published_translation";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* First MCP Sentinel release that honors `X-MCP-Draft-Mode: revise`.
|
|
29
|
+
* Older hosts keep the create 409 and have no path in between.
|
|
30
|
+
*/
|
|
31
|
+
export const MIN_SENTINEL_REVISE_VERSION = "2.24.0";
|
|
32
|
+
/** First Sentinel release that keeps a default-language draft out of translation-write rules (#379). */
|
|
33
|
+
export const MIN_SENTINEL_DEFAULT_LANGUAGE_DRAFT_VERSION = "2.24.2";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a translation inventory advertises published-translation revise.
|
|
37
|
+
* Absent on Sentinel releases before 2.24.0.
|
|
38
|
+
* @param {object} [meta] Inventory `meta`, or an object with `operations`.
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function supportsRevisePublishedTranslation(meta) {
|
|
42
|
+
return Array.isArray(meta?.operations) && meta.operations.includes(REVISE_PUBLISHED_TRANSLATION);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Refusal when the host cannot revise a published translation.
|
|
47
|
+
* @returns {Error}
|
|
48
|
+
*/
|
|
49
|
+
export function reviseCapabilityError() {
|
|
50
|
+
return new Error(
|
|
51
|
+
`This Sentinel host cannot revise a published translation (missing ${REVISE_PUBLISHED_TRANSLATION}). ` +
|
|
52
|
+
`Update MCP Sentinel to ${MIN_SENTINEL_REVISE_VERSION} or later. No write was attempted.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
24
56
|
/**
|
|
25
57
|
* Whether a backend can issue Sentinel's JSON:API draft/translation routes.
|
|
26
58
|
* Requires `capabilities().sentinelDraft` when that flag is present.
|
|
@@ -179,7 +211,8 @@ export async function writeDraft(backend, input, preflight = false) {
|
|
|
179
211
|
"If-Match": `"${live}:${working}"`,
|
|
180
212
|
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
181
213
|
};
|
|
182
|
-
const
|
|
214
|
+
const requestedLang = langcode ?? draftRevision?.langcode;
|
|
215
|
+
const targetLang = requestedLang ? assertDraftLangcode(requestedLang) : null;
|
|
183
216
|
if (targetLang) headers["X-MCP-Draft-Langcode"] = targetLang;
|
|
184
217
|
const result = await sentinelDraftRequest(backend, {
|
|
185
218
|
entityType, bundle, id, suffix: "mcp-draft", missingMessage: MISSING_DRAFT_ENDPOINT,
|
|
@@ -212,6 +245,7 @@ export async function writeDraft(backend, input, preflight = false) {
|
|
|
212
245
|
*/
|
|
213
246
|
export async function createTranslationDraft(backend, input, preflight = false) {
|
|
214
247
|
const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
|
|
248
|
+
const revise = input.revise === true;
|
|
215
249
|
const langcode = assertDraftLangcode(input.langcode);
|
|
216
250
|
if (entityType === "paragraph") {
|
|
217
251
|
return createParagraphTranslationDraft(backend, {
|
|
@@ -234,6 +268,12 @@ export async function createTranslationDraft(backend, input, preflight = false)
|
|
|
234
268
|
const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
|
|
235
269
|
if (relationships) data.relationships = relationships;
|
|
236
270
|
const ifMatch = working ? `"${live}:${working}"` : `"${live}"`;
|
|
271
|
+
const headers = {
|
|
272
|
+
"If-Match": ifMatch,
|
|
273
|
+
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
274
|
+
"X-MCP-Draft-Langcode": langcode,
|
|
275
|
+
};
|
|
276
|
+
if (revise) headers["X-MCP-Draft-Mode"] = "revise";
|
|
237
277
|
let result;
|
|
238
278
|
try {
|
|
239
279
|
result = await sentinelDraftRequest(backend, {
|
|
@@ -241,11 +281,7 @@ export async function createTranslationDraft(backend, input, preflight = false)
|
|
|
241
281
|
missingMessage: MISSING_TRANSLATION_ENDPOINT,
|
|
242
282
|
options: {
|
|
243
283
|
method: "POST",
|
|
244
|
-
headers
|
|
245
|
-
"If-Match": ifMatch,
|
|
246
|
-
"X-MCP-Draft-Preflight": preflight ? "1" : "0",
|
|
247
|
-
"X-MCP-Draft-Langcode": langcode,
|
|
248
|
-
},
|
|
284
|
+
headers,
|
|
249
285
|
body: JSON.stringify({ data }),
|
|
250
286
|
},
|
|
251
287
|
});
|
|
@@ -256,6 +292,9 @@ export async function createTranslationDraft(backend, input, preflight = false)
|
|
|
256
292
|
if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
|
|
257
293
|
throw new Error("The site did not confirm a non-saving translation preflight. Refusing to continue.");
|
|
258
294
|
}
|
|
295
|
+
if (revise && result.meta.operation !== REVISE_PUBLISHED_TRANSLATION) {
|
|
296
|
+
throw reviseCapabilityError();
|
|
297
|
+
}
|
|
259
298
|
return result;
|
|
260
299
|
}
|
|
261
300
|
if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
|
|
@@ -274,7 +313,7 @@ export async function createTranslationDraft(backend, input, preflight = false)
|
|
|
274
313
|
* alias. Permission, 5xx, and malformed inventory are not absence — they throw.
|
|
275
314
|
* @param {object} backend
|
|
276
315
|
* @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
|
|
277
|
-
* @returns {Promise<{liveVid: ?(number|string), workingVid: ?(number|string)}>}
|
|
316
|
+
* @returns {Promise<{liveVid: ?(number|string), workingVid: ?(number|string), operations: string[], inventory: ?object}>}
|
|
278
317
|
*/
|
|
279
318
|
export async function resolveNodeTranslationPair(backend, { entityType, bundle, id, existing }) {
|
|
280
319
|
const fromEntity = existing ? entityRevisionId(existing) : null;
|
|
@@ -284,7 +323,7 @@ export async function resolveNodeTranslationPair(backend, { entityType, bundle,
|
|
|
284
323
|
} catch (error) {
|
|
285
324
|
if (!isMissingTranslationEndpoint(error)) throw error;
|
|
286
325
|
if (typeof backend.getEntity !== "function") {
|
|
287
|
-
return { liveVid: fromEntity, workingVid: undefined };
|
|
326
|
+
return { liveVid: fromEntity, workingVid: undefined, operations: [], inventory: null };
|
|
288
327
|
}
|
|
289
328
|
let workingCopy = null;
|
|
290
329
|
try {
|
|
@@ -296,14 +335,71 @@ export async function resolveNodeTranslationPair(backend, { entityType, bundle,
|
|
|
296
335
|
}
|
|
297
336
|
const workingVid = workingCopy ? entityRevisionId(workingCopy) : null;
|
|
298
337
|
const distinct = workingVid !== null && fromEntity !== null && String(workingVid) !== String(fromEntity);
|
|
299
|
-
return {
|
|
338
|
+
return {
|
|
339
|
+
liveVid: fromEntity,
|
|
340
|
+
workingVid: distinct ? workingVid : undefined,
|
|
341
|
+
operations: [],
|
|
342
|
+
inventory: null,
|
|
343
|
+
};
|
|
300
344
|
}
|
|
301
345
|
const liveVid = meta.live?.vid ?? fromEntity;
|
|
302
346
|
const workingVid = meta.working?.vid;
|
|
303
347
|
const distinct = workingVid !== undefined && workingVid !== null && workingVid !== ""
|
|
304
348
|
&& liveVid !== undefined && liveVid !== null
|
|
305
349
|
&& String(workingVid) !== String(liveVid);
|
|
306
|
-
return {
|
|
350
|
+
return {
|
|
351
|
+
liveVid,
|
|
352
|
+
workingVid: distinct ? workingVid : undefined,
|
|
353
|
+
operations: Array.isArray(meta.operations) ? meta.operations : [],
|
|
354
|
+
inventory: meta,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Turn Sentinel's create-conflict 409 into the next step that can succeed.
|
|
360
|
+
* Revise on a host that still says "already exists" did not honor the mode.
|
|
361
|
+
* @param {unknown} error
|
|
362
|
+
* @param {{revise?: boolean, inventory?: object|null, langcode?: string, entityType?: string}} [context]
|
|
363
|
+
* @returns {Error}
|
|
364
|
+
*/
|
|
365
|
+
export function explainExistingTranslation(error, { revise = false, inventory = null, langcode, entityType = "node" } = {}) {
|
|
366
|
+
const current = error instanceof Error ? error : new Error(String(error));
|
|
367
|
+
// The create conflict only. "A working copy already exists" is a different 409.
|
|
368
|
+
if (!/translation for this language already exists/i.test(current.message)) return current;
|
|
369
|
+
if (revise) {
|
|
370
|
+
return new Error(
|
|
371
|
+
"Sentinel did not revise the published translation; it still reports that the language already exists. " +
|
|
372
|
+
`Update MCP Sentinel to ${MIN_SENTINEL_REVISE_VERSION} or later. No draft was opened.`,
|
|
373
|
+
{ cause: current },
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const continuer = entityType === "media"
|
|
377
|
+
? "drupal_update_media"
|
|
378
|
+
: entityType === "paragraph"
|
|
379
|
+
? "drupal_update_paragraph"
|
|
380
|
+
: "drupal_update_node";
|
|
381
|
+
const liveRow = (inventory?.live?.translations ?? []).find((row) => row?.langcode === langcode);
|
|
382
|
+
const workingDistinct = inventory?.working?.vid && inventory?.live?.vid
|
|
383
|
+
&& String(inventory.working.vid) !== String(inventory.live.vid);
|
|
384
|
+
const workingRow = (inventory?.working?.translations ?? []).find((row) => row?.langcode === langcode);
|
|
385
|
+
const defaultLang = inventory?.defaultLangcode;
|
|
386
|
+
if (liveRow && liveRow.status === true && !workingDistinct && langcode !== defaultLang && liveRow.default !== true) {
|
|
387
|
+
return new Error(
|
|
388
|
+
"A published translation for this language already exists and there is no working copy. " +
|
|
389
|
+
"Pass revise: true to drupal_create_translation to open an unpublished draft over it. " +
|
|
390
|
+
`${continuer} cannot open that draft.`,
|
|
391
|
+
{ cause: current },
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (workingDistinct && workingRow && workingRow.status === false) {
|
|
395
|
+
return new Error(
|
|
396
|
+
"An unpublished working translation for this language already exists. " +
|
|
397
|
+
`Continue it with ${continuer} and langcode. ` +
|
|
398
|
+
"Do not create it again.",
|
|
399
|
+
{ cause: current },
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
return current;
|
|
307
403
|
}
|
|
308
404
|
|
|
309
405
|
/**
|
|
@@ -373,6 +469,27 @@ export async function readNodeDraftInventory(backend, ref) {
|
|
|
373
469
|
return inventory;
|
|
374
470
|
}
|
|
375
471
|
|
|
472
|
+
/**
|
|
473
|
+
* The language to name when continuing a multilingual working revision
|
|
474
|
+
* without an explicit langcode (#379).
|
|
475
|
+
*
|
|
476
|
+
* Sentinel requires X-MCP-Draft-Langcode whenever the working revision holds
|
|
477
|
+
* more than one language, even for the default language. Infer it only when
|
|
478
|
+
* the default language is the unpublished draft in that revision; a
|
|
479
|
+
* translation-only draft over published default copy stays explicit.
|
|
480
|
+
* @param {object|null|undefined} inventory Sentinel translation inventory.
|
|
481
|
+
* @returns {string|undefined}
|
|
482
|
+
*/
|
|
483
|
+
export function inferDefaultDraftLangcode(inventory) {
|
|
484
|
+
const rows = inventory?.working?.translations ?? [];
|
|
485
|
+
if (rows.length < 2) return undefined;
|
|
486
|
+
const fallback = rows.find((row) => row?.default === true)?.langcode;
|
|
487
|
+
const defaultLang = typeof inventory.defaultLangcode === "string" ? inventory.defaultLangcode : fallback;
|
|
488
|
+
if (typeof defaultLang !== "string" || defaultLang === "") return undefined;
|
|
489
|
+
const row = rows.find((item) => item?.langcode === defaultLang);
|
|
490
|
+
return row && row.status === false ? defaultLang : undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
376
493
|
/**
|
|
377
494
|
* Ensure inventory discovery does not turn a published language into a draft.
|
|
378
495
|
* Sentinel still validates the revision pair and language on every request.
|
package/src/tools/media.js
CHANGED
|
@@ -319,7 +319,7 @@ export const definitions = [
|
|
|
319
319
|
},
|
|
320
320
|
{
|
|
321
321
|
name: "drupal_update_media",
|
|
322
|
-
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first.",
|
|
322
|
+
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched. Pass langcode to continue an unpublished working translation via Sentinel; this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. A published translation with no working copy is opened with drupal_create_translation and revise: true, not with this tool.",
|
|
323
323
|
inputSchema: {
|
|
324
324
|
type: "object", required: ["type", "id"],
|
|
325
325
|
properties: {
|
package/src/tools/nodes.js
CHANGED
|
@@ -644,7 +644,7 @@ export const definitions = [
|
|
|
644
644
|
},
|
|
645
645
|
{
|
|
646
646
|
name: "drupal_update_node",
|
|
647
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation.
|
|
647
|
+
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. Omit langcode to continue the default language: when the working revision holds several languages and the default language is its unpublished draft, the connector names it for Sentinel (changing shared fields or paragraphs there needs MCP Sentinel 2.24.2). A language that is already published and has no working copy is not continued here: call drupal_create_translation with revise: true. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. A published language with no working copy is not converted into a draft by this tool; use drupal_create_translation with revise: true. dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.",
|
|
648
648
|
inputSchema: {
|
|
649
649
|
type: "object", required: ["type", "id"],
|
|
650
650
|
properties: {
|
|
@@ -34,9 +34,13 @@ import { paragraphRevisionId } from "../lib/err-relationships.js";
|
|
|
34
34
|
import {
|
|
35
35
|
assertDraftLangcode,
|
|
36
36
|
createTranslationDraft,
|
|
37
|
+
explainExistingTranslation,
|
|
37
38
|
isMissingTranslationEndpoint,
|
|
39
|
+
MIN_SENTINEL_REVISE_VERSION,
|
|
38
40
|
readTranslationInventory,
|
|
39
41
|
resolveNodeTranslationPair,
|
|
42
|
+
reviseCapabilityError,
|
|
43
|
+
supportsRevisePublishedTranslation,
|
|
40
44
|
supportsSentinelDraft,
|
|
41
45
|
} from "../lib/sentinel-draft.js";
|
|
42
46
|
import { mapTranslationRow } from "../lib/translation-rows.js";
|
|
@@ -123,7 +127,7 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
|
|
|
123
127
|
*/
|
|
124
128
|
async function createTranslation({
|
|
125
129
|
site: siteName, entityType = "node", type, id, langcode, attributes = {},
|
|
126
|
-
relationships, revisionId, dryRun = false,
|
|
130
|
+
relationships, revisionId, dryRun = false, revise = false,
|
|
127
131
|
}) {
|
|
128
132
|
validateMachineName(entityType, "entityType");
|
|
129
133
|
validateMachineName(type, "type");
|
|
@@ -137,6 +141,12 @@ async function createTranslation({
|
|
|
137
141
|
if (entityType !== "node" && entityType !== "paragraph" && entityType !== "media") {
|
|
138
142
|
throw new Error("Governed translation create is implemented for nodes, paragraphs, and media.");
|
|
139
143
|
}
|
|
144
|
+
if (revise && entityType === "paragraph") {
|
|
145
|
+
throw new Error(
|
|
146
|
+
"Revising a published paragraph translation is not supported. " +
|
|
147
|
+
"Paragraph draft writes continue an unpublished translation on the pinned revision only.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
140
150
|
|
|
141
151
|
const backend = await resolveBackend(site);
|
|
142
152
|
const existing = await backend.getEntity({ entityType, bundle: type, id });
|
|
@@ -158,6 +168,18 @@ async function createTranslation({
|
|
|
158
168
|
draftRevision = await resolveNodeTranslationPair(backend, {
|
|
159
169
|
entityType, bundle: type, id, existing,
|
|
160
170
|
});
|
|
171
|
+
if (revise) {
|
|
172
|
+
if (!supportsRevisePublishedTranslation(draftRevision)) {
|
|
173
|
+
throw reviseCapabilityError();
|
|
174
|
+
}
|
|
175
|
+
if (draftRevision.workingVid) {
|
|
176
|
+
const continuer = entityType === "media" ? "drupal_update_media" : "drupal_update_node";
|
|
177
|
+
throw new Error(
|
|
178
|
+
"A working copy already exists. Continue that draft with " +
|
|
179
|
+
`${continuer} and langcode. Revise was not attempted.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
161
183
|
if (entityType === "node") {
|
|
162
184
|
if (drafted.status === undefined && drafted.moderation_state === undefined) {
|
|
163
185
|
drafted.moderation_state = "draft";
|
|
@@ -169,22 +191,46 @@ async function createTranslation({
|
|
|
169
191
|
assertPublishAllowed(sec, drafted);
|
|
170
192
|
}
|
|
171
193
|
|
|
194
|
+
const draftInput = {
|
|
195
|
+
entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
|
|
196
|
+
revise: revise === true,
|
|
197
|
+
};
|
|
172
198
|
if (dryRun) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
199
|
+
try {
|
|
200
|
+
await createTranslationDraft(backend, draftInput, true);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw explainExistingTranslation(error, {
|
|
203
|
+
revise: revise === true,
|
|
204
|
+
inventory: draftRevision.inventory,
|
|
205
|
+
langcode: targetLang,
|
|
206
|
+
entityType,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
176
209
|
return {
|
|
177
|
-
dryRun: true,
|
|
178
|
-
|
|
210
|
+
dryRun: true,
|
|
211
|
+
operation: revise ? "revise_translation" : "create_translation",
|
|
212
|
+
entityType, bundle: type, id,
|
|
213
|
+
langcode: targetLang,
|
|
214
|
+
...(revise ? { revise: true } : {}),
|
|
215
|
+
attributes: drafted,
|
|
216
|
+
...(relationships ? { relationships } : {}),
|
|
179
217
|
// createTranslationDraft throws unless the site confirmed a non-saving
|
|
180
218
|
// preflight of this exact payload, so the claim holds when we get here.
|
|
181
|
-
...dryRunChecks({ operation: "create_translation", preflight: PREFLIGHT_SENTINEL_DRAFT }),
|
|
219
|
+
...dryRunChecks({ operation: revise ? "revise_translation" : "create_translation", preflight: PREFLIGHT_SENTINEL_DRAFT }),
|
|
182
220
|
};
|
|
183
221
|
}
|
|
184
222
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
223
|
+
let created;
|
|
224
|
+
try {
|
|
225
|
+
created = await createTranslationDraft(backend, draftInput);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw explainExistingTranslation(error, {
|
|
228
|
+
revise: revise === true,
|
|
229
|
+
inventory: draftRevision.inventory,
|
|
230
|
+
langcode: targetLang,
|
|
231
|
+
entityType,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
188
234
|
const redacted = omitLiveComputedMetatag(redactCanonicalEntity(created, sec, entityType));
|
|
189
235
|
if (entityType === "paragraph") return redacted;
|
|
190
236
|
const workingVid = entityRevisionId(created) ?? draftRevision.workingVid;
|
|
@@ -229,6 +275,12 @@ export const definitions = [
|
|
|
229
275
|
"working revision IDs are sent (If-Match) so Sentinel will add the language on that " +
|
|
230
276
|
"draft (#282). English live title, body, status, alias, default revision, and " +
|
|
231
277
|
"paragraph ERR pins stay unchanged. An existing translation is a conflict, not an overwrite. " +
|
|
278
|
+
"Pass revise: true to open an unpublished draft over a language that is already published " +
|
|
279
|
+
"on the live revision and has no working copy (Sentinel X-MCP-Draft-Mode: revise). " +
|
|
280
|
+
`That requires MCP Sentinel ${MIN_SENTINEL_REVISE_VERSION} or later; an older host is refused ` +
|
|
281
|
+
"before any write and the message names that version. dryRun uses the same translations " +
|
|
282
|
+
"endpoint and Sentinel's non-saving preflight. A working copy is refused. " +
|
|
283
|
+
"Paragraph revise is not supported. " +
|
|
232
284
|
"The response includes `_revisions.live` / `_revisions.working` when known. " +
|
|
233
285
|
"Computed `metatag` is omitted on the draft body because JSON:API resolves it from the live default (#283); use field_metatags. " +
|
|
234
286
|
"Continue a node draft with drupal_update_node and langcode; a paragraph with " +
|
|
@@ -248,7 +300,8 @@ export const definitions = [
|
|
|
248
300
|
attributes: { type: "object", description: "Translated field values keyed by Drupal machine name" },
|
|
249
301
|
relationships: { type: "object", description: "JSON:API relationships. Use for image alt (same file UUID, meta.alt)." },
|
|
250
302
|
revisionId: { type: "string", description: "Paragraph revision id the host already pins. Required for Home-shaped non-default pins." },
|
|
251
|
-
dryRun: { type: "boolean", description: "Validate without saving. Sentinel's non-saving translation preflight receives the real fields, applies them through field access and validates the entity. The result's `checks` block says what was checked." },
|
|
303
|
+
dryRun: { type: "boolean", description: "Validate without saving. Sentinel's non-saving translation preflight receives the real fields, applies them through field access and validates the entity. The result's `checks` block says what was checked. With revise: true the preflight is the same translations endpoint." },
|
|
304
|
+
revise: { type: "boolean", description: "Open an unpublished draft over a translation that is already published on the live default revision and has no working copy. Requires MCP Sentinel 2.24.0 or later. Omit it to create a language that does not exist yet." },
|
|
252
305
|
},
|
|
253
306
|
},
|
|
254
307
|
},
|