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.
@@ -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. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. 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."
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. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. 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.
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 + create entity translations |
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.22.0",
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 { writeDraft, readNodeDraftInventory, assertInventoryDraftLanguage } from "./sentinel-draft.js";
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
- if (target.inventory) assertInventoryDraftLanguage(target.inventory, langcode);
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
- if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
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
- target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
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 writeDraft(backend, {
452
- entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
453
- }, true);
491
+ await preflightDraft(inferredLangcode);
454
492
  } catch (err) {
455
- throw rewriteStaleCopyError(err);
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 targetLang = langcode ? assertDraftLangcode(langcode) : null;
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 { liveVid: fromEntity, workingVid: distinct ? workingVid : undefined };
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 { liveVid, workingVid: distinct ? workingVid : undefined };
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.
@@ -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: {
@@ -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. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. 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.",
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
- await createTranslationDraft(backend, {
174
- entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
175
- }, true);
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, operation: "create_translation", entityType, bundle: type, id,
178
- langcode: targetLang, attributes: drafted, ...(relationships ? { relationships } : {}),
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
- const created = await createTranslationDraft(backend, {
186
- entityType, bundle: type, id, langcode: targetLang, attributes: drafted, relationships, draftRevision,
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
  },