drupal-mcp-connector 2.21.0 → 2.23.0

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. 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. 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,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.23.0] - 2026-09-23
11
+
12
+ ### Added
13
+ - **Revise a published translation (#376).** `drupal_create_translation` accepts
14
+ `revise: true` to open an unpublished draft over a language that is already
15
+ published on the live revision and has no working copy. The call uses
16
+ Sentinel's translations endpoint and `X-MCP-Draft-Mode: revise`, including
17
+ on `dryRun`. A host that does not advertise `revise_published_translation`
18
+ is refused before any write; the message names MCP Sentinel 2.24.0.
19
+ `drupal_update_node` and `drupal_update_media` with `langcode` name that
20
+ flag instead of sending the caller to a create that is certain to 409.
21
+ A plain create that hits the existing-language 409 says to pass `revise: true`
22
+ when the language is published and there is no working copy, and to continue
23
+ with the update tool when an unpublished working translation already exists.
24
+
25
+ ## [2.22.0] - 2026-09-20
26
+
27
+ ### Added
28
+ - **Remote Drupal workflow prompts (#371).** The module-workflow loader fetches
29
+ `prompts/list` and `prompts/get` from `serverTools.url` (contrib `mcp_server`
30
+ `McpPromptConfig` entities) and merges them into the v1 path. A remote
31
+ workflow is listed only when local `serverTools.modules.workflows` names it
32
+ and every `{tool:alias}` is already in `serverTools.modules.tools`. Local
33
+ bodies remain the fallback when the catalog omits an enabled id. Write
34
+ workflows still require `mcp_write` and append that module writes are not
35
+ retried. See `docs/module-workflows.md`.
36
+
10
37
  ## [2.21.0] - 2026-09-19
11
38
 
12
39
  ### 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.21.0",
3
+ "version": "2.23.0",
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",
package/src/index.js CHANGED
@@ -64,7 +64,7 @@ import {
64
64
  loadWorkflows,
65
65
  toPromptDescriptor,
66
66
  renderWorkflowMessages,
67
- moduleWorkflowProviders,
67
+ moduleWorkflowProvidersWithRemote,
68
68
  registerBuiltinWorkflows,
69
69
  replaceModuleWorkflows,
70
70
  } from "./lib/workflow-prompts.js";
@@ -231,8 +231,11 @@ const buildConnectorServer = createConnectorServerFactory({
231
231
  workflowNames: WORKFLOW_PROMPT_NAMES,
232
232
  workflowMessages: getPromptMessages,
233
233
  definitionsByName,
234
- extraWorkflows: (tools, taken) => {
235
- const loaded = loadWorkflows(moduleWorkflowProviders(listResolvableSiteConfigs()), { tools, taken });
234
+ extraWorkflows: async (tools, taken) => {
235
+ const loaded = loadWorkflows(
236
+ await moduleWorkflowProvidersWithRemote(listResolvableSiteConfigs()),
237
+ { tools, taken },
238
+ );
236
239
  replaceModuleWorkflows(loaded);
237
240
  return loaded;
238
241
  },
@@ -261,6 +261,11 @@ export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundl
261
261
  return { resourceVersion: `id:${workingVid}`, workingCopy: null,
262
262
  liveVid, workingVid, inventory };
263
263
  }
264
+ if (!workingCopy) {
265
+ return {
266
+ resourceVersion: undefined, workingCopy: null, liveVid, workingVid: null, inventory,
267
+ };
268
+ }
264
269
  }
265
270
  }
266
271
  if (!workingCopy) {
@@ -425,14 +430,32 @@ export async function prepareGuardedPatch(backend, {
425
430
  throw new StaleCopyError();
426
431
  }
427
432
  }
428
- if (target.inventory) assertInventoryDraftLanguage(target.inventory, langcode);
433
+ if (!langcode && target.inventory) assertInventoryDraftLanguage(target.inventory, langcode);
429
434
  if (langcode) {
430
- if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
435
+ const noWorking = !target.workingVid || !target.liveVid
436
+ || String(target.workingVid) === String(target.liveVid);
437
+ if (noWorking) {
438
+ const liveRow = (target.inventory?.live?.translations ?? [])
439
+ .find((row) => row?.langcode === langcode);
440
+ if (liveRow && liveRow.status === true) {
441
+ if (langcode === target.inventory?.defaultLangcode || liveRow.default === true) {
442
+ throw new Error(
443
+ "The default language is not opened with drupal_create_translation. " +
444
+ "Omit langcode to draft it. A canonical langcode PATCH is not attempted."
445
+ );
446
+ }
447
+ throw new Error(
448
+ "This language is published on the live revision and has no working copy. " +
449
+ "Revise it with drupal_create_translation and revise: true. " +
450
+ "A canonical langcode PATCH is not attempted."
451
+ );
452
+ }
431
453
  throw new Error(
432
454
  "No unpublished working translation for this language. " +
433
455
  "Create it with drupal_create_translation first; a canonical langcode PATCH is not attempted."
434
456
  );
435
457
  }
458
+ if (target.inventory) assertInventoryDraftLanguage(target.inventory, langcode);
436
459
  target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
437
460
  try {
438
461
  await writeDraft(backend, {
@@ -21,6 +21,36 @@ 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
+
33
+ /**
34
+ * Whether a translation inventory advertises published-translation revise.
35
+ * Absent on Sentinel releases before 2.24.0.
36
+ * @param {object} [meta] Inventory `meta`, or an object with `operations`.
37
+ * @returns {boolean}
38
+ */
39
+ export function supportsRevisePublishedTranslation(meta) {
40
+ return Array.isArray(meta?.operations) && meta.operations.includes(REVISE_PUBLISHED_TRANSLATION);
41
+ }
42
+
43
+ /**
44
+ * Refusal when the host cannot revise a published translation.
45
+ * @returns {Error}
46
+ */
47
+ export function reviseCapabilityError() {
48
+ return new Error(
49
+ `This Sentinel host cannot revise a published translation (missing ${REVISE_PUBLISHED_TRANSLATION}). ` +
50
+ `Update MCP Sentinel to ${MIN_SENTINEL_REVISE_VERSION} or later. No write was attempted.`,
51
+ );
52
+ }
53
+
24
54
  /**
25
55
  * Whether a backend can issue Sentinel's JSON:API draft/translation routes.
26
56
  * Requires `capabilities().sentinelDraft` when that flag is present.
@@ -212,6 +242,7 @@ export async function writeDraft(backend, input, preflight = false) {
212
242
  */
213
243
  export async function createTranslationDraft(backend, input, preflight = false) {
214
244
  const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
245
+ const revise = input.revise === true;
215
246
  const langcode = assertDraftLangcode(input.langcode);
216
247
  if (entityType === "paragraph") {
217
248
  return createParagraphTranslationDraft(backend, {
@@ -234,6 +265,12 @@ export async function createTranslationDraft(backend, input, preflight = false)
234
265
  const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
235
266
  if (relationships) data.relationships = relationships;
236
267
  const ifMatch = working ? `"${live}:${working}"` : `"${live}"`;
268
+ const headers = {
269
+ "If-Match": ifMatch,
270
+ "X-MCP-Draft-Preflight": preflight ? "1" : "0",
271
+ "X-MCP-Draft-Langcode": langcode,
272
+ };
273
+ if (revise) headers["X-MCP-Draft-Mode"] = "revise";
237
274
  let result;
238
275
  try {
239
276
  result = await sentinelDraftRequest(backend, {
@@ -241,11 +278,7 @@ export async function createTranslationDraft(backend, input, preflight = false)
241
278
  missingMessage: MISSING_TRANSLATION_ENDPOINT,
242
279
  options: {
243
280
  method: "POST",
244
- headers: {
245
- "If-Match": ifMatch,
246
- "X-MCP-Draft-Preflight": preflight ? "1" : "0",
247
- "X-MCP-Draft-Langcode": langcode,
248
- },
281
+ headers,
249
282
  body: JSON.stringify({ data }),
250
283
  },
251
284
  });
@@ -256,6 +289,9 @@ export async function createTranslationDraft(backend, input, preflight = false)
256
289
  if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
257
290
  throw new Error("The site did not confirm a non-saving translation preflight. Refusing to continue.");
258
291
  }
292
+ if (revise && result.meta.operation !== REVISE_PUBLISHED_TRANSLATION) {
293
+ throw reviseCapabilityError();
294
+ }
259
295
  return result;
260
296
  }
261
297
  if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
@@ -274,7 +310,7 @@ export async function createTranslationDraft(backend, input, preflight = false)
274
310
  * alias. Permission, 5xx, and malformed inventory are not absence — they throw.
275
311
  * @param {object} backend
276
312
  * @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
277
- * @returns {Promise<{liveVid: ?(number|string), workingVid: ?(number|string)}>}
313
+ * @returns {Promise<{liveVid: ?(number|string), workingVid: ?(number|string), operations: string[], inventory: ?object}>}
278
314
  */
279
315
  export async function resolveNodeTranslationPair(backend, { entityType, bundle, id, existing }) {
280
316
  const fromEntity = existing ? entityRevisionId(existing) : null;
@@ -284,7 +320,7 @@ export async function resolveNodeTranslationPair(backend, { entityType, bundle,
284
320
  } catch (error) {
285
321
  if (!isMissingTranslationEndpoint(error)) throw error;
286
322
  if (typeof backend.getEntity !== "function") {
287
- return { liveVid: fromEntity, workingVid: undefined };
323
+ return { liveVid: fromEntity, workingVid: undefined, operations: [], inventory: null };
288
324
  }
289
325
  let workingCopy = null;
290
326
  try {
@@ -296,14 +332,71 @@ export async function resolveNodeTranslationPair(backend, { entityType, bundle,
296
332
  }
297
333
  const workingVid = workingCopy ? entityRevisionId(workingCopy) : null;
298
334
  const distinct = workingVid !== null && fromEntity !== null && String(workingVid) !== String(fromEntity);
299
- return { liveVid: fromEntity, workingVid: distinct ? workingVid : undefined };
335
+ return {
336
+ liveVid: fromEntity,
337
+ workingVid: distinct ? workingVid : undefined,
338
+ operations: [],
339
+ inventory: null,
340
+ };
300
341
  }
301
342
  const liveVid = meta.live?.vid ?? fromEntity;
302
343
  const workingVid = meta.working?.vid;
303
344
  const distinct = workingVid !== undefined && workingVid !== null && workingVid !== ""
304
345
  && liveVid !== undefined && liveVid !== null
305
346
  && String(workingVid) !== String(liveVid);
306
- return { liveVid, workingVid: distinct ? workingVid : undefined };
347
+ return {
348
+ liveVid,
349
+ workingVid: distinct ? workingVid : undefined,
350
+ operations: Array.isArray(meta.operations) ? meta.operations : [],
351
+ inventory: meta,
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Turn Sentinel's create-conflict 409 into the next step that can succeed.
357
+ * Revise on a host that still says "already exists" did not honor the mode.
358
+ * @param {unknown} error
359
+ * @param {{revise?: boolean, inventory?: object|null, langcode?: string, entityType?: string}} [context]
360
+ * @returns {Error}
361
+ */
362
+ export function explainExistingTranslation(error, { revise = false, inventory = null, langcode, entityType = "node" } = {}) {
363
+ const current = error instanceof Error ? error : new Error(String(error));
364
+ // The create conflict only. "A working copy already exists" is a different 409.
365
+ if (!/translation for this language already exists/i.test(current.message)) return current;
366
+ if (revise) {
367
+ return new Error(
368
+ "Sentinel did not revise the published translation; it still reports that the language already exists. " +
369
+ `Update MCP Sentinel to ${MIN_SENTINEL_REVISE_VERSION} or later. No draft was opened.`,
370
+ { cause: current },
371
+ );
372
+ }
373
+ const continuer = entityType === "media"
374
+ ? "drupal_update_media"
375
+ : entityType === "paragraph"
376
+ ? "drupal_update_paragraph"
377
+ : "drupal_update_node";
378
+ const liveRow = (inventory?.live?.translations ?? []).find((row) => row?.langcode === langcode);
379
+ const workingDistinct = inventory?.working?.vid && inventory?.live?.vid
380
+ && String(inventory.working.vid) !== String(inventory.live.vid);
381
+ const workingRow = (inventory?.working?.translations ?? []).find((row) => row?.langcode === langcode);
382
+ const defaultLang = inventory?.defaultLangcode;
383
+ if (liveRow && liveRow.status === true && !workingDistinct && langcode !== defaultLang && liveRow.default !== true) {
384
+ return new Error(
385
+ "A published translation for this language already exists and there is no working copy. " +
386
+ "Pass revise: true to drupal_create_translation to open an unpublished draft over it. " +
387
+ `${continuer} cannot open that draft.`,
388
+ { cause: current },
389
+ );
390
+ }
391
+ if (workingDistinct && workingRow && workingRow.status === false) {
392
+ return new Error(
393
+ "An unpublished working translation for this language already exists. " +
394
+ `Continue it with ${continuer} and langcode. ` +
395
+ "Do not create it again.",
396
+ { cause: current },
397
+ );
398
+ }
399
+ return current;
307
400
  }
308
401
 
309
402
  /**
@@ -13,7 +13,8 @@
13
13
  * call site performs the MCP session handshake — `initialize` (read the
14
14
  * `Mcp-Session-Id` response header) → `notifications/initialized` → `tools/call`
15
15
  * carrying that session id. The session is cached per site and transparently
16
- * re-initialised when the server expires it.
16
+ * re-initialised when the server expires it. `prompts/list` and `prompts/get`
17
+ * reuse the same session.
17
18
  *
18
19
  * Config (per site):
19
20
  * "serverTools": { "url": "/mcp" } // path is resolved against site.baseUrl
@@ -333,7 +334,7 @@ async function initializeSession(site, endpoint, key) {
333
334
  method: "initialize",
334
335
  params: {
335
336
  protocolVersion: MCP_PROTOCOL_VERSION,
336
- capabilities: {},
337
+ capabilities: { prompts: {} },
337
338
  clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
338
339
  },
339
340
  };
@@ -442,6 +443,74 @@ export async function listServerTools(site, cursor) {
442
443
  });
443
444
  }
444
445
 
446
+ /** Fetch one page of Drupal `McpPromptConfig` prompts. */
447
+ export async function listServerPrompts(site, cursor) {
448
+ return requestServerTool(site, "prompts/list", cursor === undefined ? {} : { cursor }, {
449
+ maxBytes: SERVER_TOOL_MAX_BYTES, preserveErrors: true,
450
+ });
451
+ }
452
+
453
+ /**
454
+ * Fetch one Drupal prompt body. Pass no arguments so `{{token}}` placeholders
455
+ * stay intact for the connector's workflow renderer.
456
+ */
457
+ export async function getServerPrompt(site, name) {
458
+ return requestServerTool(site, "prompts/get", { name, arguments: {} }, {
459
+ maxBytes: SERVER_TOOL_MAX_BYTES, preserveErrors: true,
460
+ });
461
+ }
462
+
463
+ /**
464
+ * Page `prompts/list` the same way as `tools/list`.
465
+ * @param {object} site Resolved site config.
466
+ * @param {Function} [list] Catalog page reader.
467
+ * @returns {Promise<object[]>} Prompt descriptors.
468
+ */
469
+ export async function advertisedServerPrompts(site, list = listServerPrompts) {
470
+ const prompts = [];
471
+ const seen = new Set();
472
+ let cursor;
473
+ for (let page = 0; page < MAX_CATALOG_PAGES; page++) {
474
+ const result = await list(site, cursor);
475
+ if (!Array.isArray(result?.prompts)) {
476
+ throw new Error(`Server-tool catalog for site "${site._name}" is malformed: prompts/list returned no prompts array.`);
477
+ }
478
+ for (const prompt of result.prompts) {
479
+ if (prompt && typeof prompt.name === "string") prompts.push(prompt);
480
+ }
481
+ if (result.nextCursor === undefined || result.nextCursor === null) return prompts;
482
+ if (typeof result.nextCursor !== "string" || seen.has(result.nextCursor)) {
483
+ throw new Error(`Server-tool catalog for site "${site._name}" returned an invalid or repeated prompts cursor.`);
484
+ }
485
+ cursor = result.nextCursor;
486
+ seen.add(cursor);
487
+ }
488
+ throw new Error(`Server-tool catalog for site "${site._name}" exceeds ${MAX_CATALOG_PAGES} prompt pages.`);
489
+ }
490
+
491
+ /**
492
+ * Load listed prompt descriptors plus `prompts/get` bodies for the named ids.
493
+ * A get failure omits that id; the caller fail-closes rather than widening.
494
+ * @param {object} site Resolved site config.
495
+ * @param {string[]} names Workflow ids to fetch.
496
+ * @param {{list?: Function, get?: Function}} [deps]
497
+ * @returns {Promise<{list: object[], bodies: Map<string, object>}>}
498
+ */
499
+ export async function fetchSiteWorkflowPrompts(site, names, { list = listServerPrompts, get = getServerPrompt } = {}) {
500
+ const listed = await advertisedServerPrompts(site, list);
501
+ const want = new Set(names ?? []);
502
+ const bodies = new Map();
503
+ for (const prompt of listed) {
504
+ if (!want.has(prompt.name)) continue;
505
+ try {
506
+ bodies.set(prompt.name, await get(site, prompt.name));
507
+ } catch {
508
+ // Fail closed for this id: no body, so the merge will not list it.
509
+ }
510
+ }
511
+ return { list: listed, bodies };
512
+ }
513
+
445
514
  /** Shared bounded MCP request transport; size + abort are always attached. */
446
515
  async function requestServerTool(site, method, params, options) {
447
516
  const toolName = params.name ?? method;
@@ -239,7 +239,7 @@ export function getToolPromptMessages(promptName, args = {}, definitionsByName)
239
239
  * @param {Set<string>} options.workflowNames - Names of the hand-authored workflow prompts.
240
240
  * @param {(name: string, args: object) => Array<object>} options.workflowMessages
241
241
  * @param {Map<string,object>} options.definitionsByName - Built-in tool name → definition.
242
- * @param {(tools: Array<object>, taken: Set<string>) => object[]} [options.extraWorkflows]
242
+ * @param {(tools: Array<object>, taken: Set<string>) => object[]|Promise<object[]>} [options.extraWorkflows]
243
243
  * Module-owned workflows visible for this request. Each item has name,
244
244
  * description, arguments, and is renderable by extraWorkflowMessages.
245
245
  * @param {(workflow: object, args: object) => Array<object>} [options.extraWorkflowMessages]
@@ -256,7 +256,7 @@ export function createPromptSurface({
256
256
  async function visible() {
257
257
  const tools = await discover();
258
258
  const taken = new Set(staticPrompts.map((prompt) => prompt.name));
259
- const extra = extraWorkflows ? extraWorkflows(tools, taken) : [];
259
+ const extra = extraWorkflows ? await extraWorkflows(tools, taken) : [];
260
260
  for (const workflow of extra) taken.add(workflow.name);
261
261
  // A reserved module name cannot match a built-in, but never let a remote
262
262
  // catalog shadow a static prompt if that invariant is ever broken.
@@ -265,6 +265,148 @@ export function renderWorkflowMessages(workflow, args = {}) {
265
265
  return [{ role: "user", content: { type: "text", text } }];
266
266
  }
267
267
 
268
+ /**
269
+ * Unique `{tool:alias}` names in instruction text, first-seen order.
270
+ *
271
+ * @param {string} instructions
272
+ * @returns {string[]}
273
+ */
274
+ export function toolAliasesInInstructions(instructions) {
275
+ const aliases = [];
276
+ const seen = new Set();
277
+ for (const match of String(instructions ?? "").matchAll(/\{tool:([a-z][a-z0-9_]{0,47})\}/g)) {
278
+ if (seen.has(match[1])) continue;
279
+ seen.add(match[1]);
280
+ aliases.push(match[1]);
281
+ }
282
+ return aliases;
283
+ }
284
+
285
+ /**
286
+ * Join user-role text from a Drupal `prompts/get` result.
287
+ *
288
+ * @param {object} got
289
+ * @returns {string}
290
+ */
291
+ export function instructionTextFromDrupalPrompt(got) {
292
+ const messages = Array.isArray(got?.messages) ? got.messages : [];
293
+ const parts = [];
294
+ for (const message of messages) {
295
+ if (message?.role !== "user") continue;
296
+ const content = message.content;
297
+ if (typeof content?.text === "string") {
298
+ parts.push(content.text);
299
+ continue;
300
+ }
301
+ if (Array.isArray(content)) {
302
+ for (const item of content) {
303
+ if (item?.type === "text" && typeof item.text === "string") parts.push(item.text);
304
+ }
305
+ }
306
+ }
307
+ return parts.join("\n").replace(/\{\{\s*([a-z][a-z0-9_]{0,47})\s*\}\}/g, "{arg:$1}");
308
+ }
309
+
310
+ /**
311
+ * `readOnly` is false when any named alias is a local write or delete.
312
+ *
313
+ * @param {string[]} aliases
314
+ * @param {object} toolsMap `serverTools.modules.tools`
315
+ * @returns {boolean}
316
+ */
317
+ export function inferReadOnly(aliases, toolsMap) {
318
+ for (const alias of aliases) {
319
+ const operation = toolsMap?.[alias]?.operation;
320
+ if (operation === "write" || operation === "delete") return false;
321
+ }
322
+ return true;
323
+ }
324
+
325
+ /**
326
+ * Map a Drupal prompt list item + get body onto the v1 workflow definition.
327
+ * Returns null when the prompt cannot be a workflow (no `{tool:alias}`, an
328
+ * alias missing from the local tools map, or an unusable description).
329
+ *
330
+ * @param {string} id
331
+ * @param {object} listed
332
+ * @param {object} got
333
+ * @param {object} toolsMap
334
+ * @returns {object|null}
335
+ */
336
+ export function workflowFromDrupalPrompt(id, listed, got, toolsMap) {
337
+ const instructions = instructionTextFromDrupalPrompt(got);
338
+ if (!instructions.trim()) return null;
339
+ const tools = toolAliasesInInstructions(instructions);
340
+ if (tools.length < 1) return null;
341
+ for (const alias of tools) {
342
+ if (!toolsMap?.[alias] || typeof toolsMap[alias] !== "object") return null;
343
+ }
344
+ const rawArgs = Array.isArray(listed?.arguments) ? listed.arguments : [];
345
+ const arguments_ = rawArgs.map((arg) => ({
346
+ name: arg?.name ?? arg?.machine_name,
347
+ description: arg?.description ?? "",
348
+ required: Boolean(arg?.required),
349
+ }));
350
+ const description = String(listed?.description ?? got?.description ?? "").trim() || "Module workflow.";
351
+ return {
352
+ id,
353
+ description,
354
+ readOnly: inferReadOnly(tools, toolsMap),
355
+ tools,
356
+ arguments: arguments_,
357
+ instructions,
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Provider from site `serverTools.modules.workflows` maps, optionally merged
363
+ * with Drupal `prompts/list` + `prompts/get`. Local keys enable; remote bodies
364
+ * replace a local body when the catalog returns that id. A remote prompt that
365
+ * is not named locally is ignored. Fetch failures keep the v1 local bodies.
366
+ *
367
+ * @param {Array<object>} sites
368
+ * @param {{fetch?: Function}} [deps]
369
+ * @returns {Promise<object[]>}
370
+ */
371
+ export async function moduleWorkflowProvidersWithRemote(sites, { fetch } = {}) {
372
+ const { fetchSiteWorkflowPrompts } = await import("./server-tools.js");
373
+ const load = fetch ?? fetchSiteWorkflowPrompts;
374
+ const providers = [];
375
+ for (const site of sites ?? []) {
376
+ const modules = site.serverTools?.modules;
377
+ if (!modules?.namespace || !modules.workflows || typeof modules.workflows !== "object") continue;
378
+ const enabled = [];
379
+ for (const [id, body] of Object.entries(modules.workflows)) {
380
+ if (id.startsWith("_") || !body || typeof body !== "object") continue;
381
+ enabled.push({ id: body.id ?? id, body });
382
+ }
383
+ let catalog;
384
+ try {
385
+ catalog = await load(site, enabled.map((item) => item.id));
386
+ } catch {
387
+ catalog = { list: [], bodies: new Map() };
388
+ }
389
+ const listedByName = new Map((catalog.list ?? []).map((prompt) => [prompt.name, prompt]));
390
+ const workflows = [];
391
+ for (const item of enabled) {
392
+ const listed = listedByName.get(item.id);
393
+ const got = catalog.bodies?.get(item.id);
394
+ if (listed && got) {
395
+ const mapped = workflowFromDrupalPrompt(item.id, listed, got, modules.tools ?? {});
396
+ if (mapped) workflows.push(mapped);
397
+ continue;
398
+ }
399
+ workflows.push({ ...item.body, id: item.id });
400
+ }
401
+ providers.push({
402
+ id: `config:${modules.namespace}`,
403
+ namespace: modules.namespace,
404
+ workflows,
405
+ });
406
+ }
407
+ return providers;
408
+ }
409
+
268
410
  /**
269
411
  * Provider from site `serverTools.modules.workflows` maps.
270
412
  *
@@ -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. 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
  },