drupal-mcp-connector 2.15.1 → 2.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge."
2
+ description: "Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge."
3
3
  argument-hint: "<type> <id> [site]"
4
4
  ---
5
5
 
6
6
  Call the MCP tool `drupal_list_revisions`.
7
7
 
8
- Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.
8
+ Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.
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. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents."
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."
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. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.
8
+ Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. 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.
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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.15.2] - 2026-09-11
11
+
12
+ ### Fixed
13
+ - **Discover and continue translation-only forward revisions (#297).** When core's
14
+ working-copy alias is absent or echoes live, node revision listing and guarded
15
+ updates consult Sentinel's translation inventory. Existing unpublished languages
16
+ continue through the governed endpoint with explicit language and live/working
17
+ preconditions. Published languages and ambiguous multilingual edits remain refused.
18
+ Permission and transport failures stop discovery rather than becoming “no draft”.
19
+ Update responses preserve the saved translation instead of replacing it with live
20
+ English. An unresolved revision conflict no longer prescribes revision surgery.
21
+
10
22
  ## [2.15.1] - 2026-09-10
11
23
 
12
24
  ### Fixed
@@ -1571,6 +1583,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1571
1583
  - User tools gained explicit PII-access assertions.
1572
1584
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1573
1585
 
1586
+ [2.15.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.2
1574
1587
  [2.15.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.1
1575
1588
  [2.15.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.15.0
1576
1589
  [2.14.2]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.14.2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.15.1",
3
+ "version": "2.15.2",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL access, governed draft translations, content tools, audit reports, and an SSH Drush bridge.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,47 @@
1
+ /** Sentinel discovery for translation-only node revisions (#297). */
2
+ import { readTranslationInventory } from "./draft-write.js";
3
+
4
+ /**
5
+ * Read optional inventory, falling back only when the endpoint is unsupported.
6
+ * Permission, transport and malformed-response failures are not absence.
7
+ * @param {object} backend
8
+ * @param {{entityType: string, bundle: string, id: string}} ref
9
+ * @returns {Promise<object|null>}
10
+ */
11
+ export async function readNodeDraftInventory(backend, ref) {
12
+ if (ref.entityType !== "node" || typeof backend.rawQuery !== "function"
13
+ || typeof backend.resourcePath !== "function") return null;
14
+ let inventory;
15
+ try {
16
+ inventory = await readTranslationInventory(backend, ref);
17
+ } catch (error) {
18
+ if (/does not provide Sentinel's governed draft-translation endpoint/.test(error.message)) return null;
19
+ throw error;
20
+ }
21
+ const validVid = (vid) => /^[1-9]\d*$/.test(String(vid ?? "")) && Number.isSafeInteger(Number(vid));
22
+ if (!validVid(inventory.live?.vid)
23
+ || (inventory.working && (!validVid(inventory.working.vid)
24
+ || !Array.isArray(inventory.working.translations)
25
+ || inventory.working.translations.some((row) => !row || typeof row.langcode !== "string"
26
+ || typeof row.status !== "boolean")))) {
27
+ throw new Error("Sentinel returned an invalid node revision inventory. Re-read before updating.");
28
+ }
29
+ return inventory;
30
+ }
31
+
32
+ /**
33
+ * Ensure inventory discovery does not turn a published language into a draft.
34
+ * Sentinel still validates the revision pair and language on every request.
35
+ * @param {object} inventory
36
+ * @param {string|undefined} langcode
37
+ */
38
+ export function assertInventoryDraftLanguage(inventory, langcode) {
39
+ const rows = inventory.working?.translations ?? [];
40
+ if (!langcode && rows.length !== 1) {
41
+ throw new Error("This working revision contains translations. Pass an explicit langcode for an existing unpublished language; no draft was created.");
42
+ }
43
+ const row = langcode ? rows.find((item) => item.langcode === langcode) : rows[0];
44
+ if (!row || row.status !== false) {
45
+ throw new Error("The requested language is not an existing unpublished working draft. Published languages and other drafts were left unchanged.");
46
+ }
47
+ }
@@ -16,8 +16,8 @@
16
16
  * Sentinel's /mcp-draft endpoint with verified live/working revision IDs.
17
17
  * Core rejects resourceVersion on PATCH. Never retry the canonical URL.
18
18
  * - **#201** — the working-copy alias does not resolve, but core still
19
- * blocks. That is a stray revision row. Refuse with revision-surgery
20
- * language. `workingCopy: null` is not proof the node is writable.
19
+ * blocks. Consult Sentinel inventory for translation-only revisions before
20
+ * refusing the unresolved revision conflict. `workingCopy: null` is not proof the node is writable.
21
21
  *
22
22
  * Unsupported endpoints and stale revisions fail closed, without discard.
23
23
  */
@@ -25,6 +25,7 @@
25
25
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
26
26
  import { entityRevisionId } from "./write-revision.js";
27
27
  import { writeDraft } from "./draft-write.js";
28
+ import { readNodeDraftInventory, assertInventoryDraftLanguage } from "./node-draft-inventory.js";
28
29
 
29
30
  /** Stable error code for a core working-copy / not-latest-revision block. */
30
31
  export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
@@ -48,7 +49,7 @@ export const PATCH_BLOCKED_MESSAGE =
48
49
  "This entity cannot be updated over JSON:API because the stored entity is not " +
49
50
  "the latest revision (Drupal core #2795279). The JSON:API aliases " +
50
51
  "rel:latest-version and rel:working-copy cannot show the blocking row. " +
51
- "Clearing it requires revision surgery outside JSON:API (Drush / the entity API). " +
52
+ "Inspect the translation inventory and revision history before considering repair; do not delete drafts. " +
52
53
  "See connector #201. Do not retry the same canonical PATCH.";
53
54
 
54
55
  /**
@@ -248,17 +249,32 @@ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
248
249
  */
249
250
  export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
250
251
  const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
252
+ if (workingCopy?.id && workingCopy.id !== id) {
253
+ throw new PatchTargetAmbiguousError(id, workingCopy.id);
254
+ }
251
255
  let liveVid = entityRevisionId(existing);
252
256
  if (workingCopy && (liveVid === null || liveVid === undefined) && typeof backend?.getEntity === "function") {
253
257
  const live = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
254
258
  liveVid = entityRevisionId(live);
255
259
  }
260
+ const aliasVid = entityRevisionId(workingCopy);
261
+ if (!workingCopy || (liveVid !== null && String(aliasVid) === String(liveVid))) {
262
+ const inventory = await readNodeDraftInventory(backend, { entityType, bundle, id });
263
+ if (inventory) {
264
+ if (liveVid !== null && String(liveVid) !== String(inventory.live.vid)) {
265
+ throw new WorkingCopyStaleError(new Error("Sentinel's live revision changed during discovery."));
266
+ }
267
+ liveVid = Number(inventory.live.vid);
268
+ const workingVid = inventory.working ? Number(inventory.working.vid) : null;
269
+ if (workingVid !== null && workingVid !== liveVid) {
270
+ return { resourceVersion: `id:${workingVid}`, workingCopy: null,
271
+ liveVid, workingVid, inventory };
272
+ }
273
+ }
274
+ }
256
275
  if (!workingCopy) {
257
276
  return { resourceVersion: undefined, workingCopy: null, liveVid, workingVid: null };
258
277
  }
259
- if (workingCopy.id && workingCopy.id !== id) {
260
- throw new PatchTargetAmbiguousError(id, workingCopy.id);
261
- }
262
278
  const workingVid = entityRevisionId(workingCopy);
263
279
  if (workingVid !== null && liveVid !== null && String(workingVid) === String(liveVid)) {
264
280
  return { resourceVersion: undefined, workingCopy, liveVid, workingVid };
@@ -402,6 +418,7 @@ export async function prepareGuardedPatch(backend, {
402
418
  throw new StaleCopyError();
403
419
  }
404
420
  }
421
+ if (target.inventory) assertInventoryDraftLanguage(target.inventory, langcode);
405
422
  if (langcode) {
406
423
  if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
407
424
  throw new Error(
@@ -65,7 +65,10 @@ export async function attachWrittenRevisionPair({
65
65
  backend, entityType, bundle, id, entity, liveVid,
66
66
  }) {
67
67
  if (liveVid === null || liveVid === undefined || !entity) return entity;
68
- let workingVid = null;
68
+ let workingVid = entityRevisionId(entity);
69
+ if (workingVid !== null && String(workingVid) !== String(liveVid)) {
70
+ return attachRevisionPair(entity, { live: liveVid, working: workingVid });
71
+ }
69
72
  if (typeof backend?.getEntity === "function") {
70
73
  try {
71
74
  const wc = await backend.getEntity({
@@ -93,12 +96,16 @@ export async function attachWrittenRevisionPair({
93
96
  * for the write (e.g. `rel:working-copy`). PreferCanonical re-reads this
94
97
  * resource instead of the default revision so a draft PATCH is not
95
98
  * replaced by the live body.
99
+ * @param {?string} [args.langcode] Language returned by governed draft continuation.
96
100
  * @returns {Promise<object>} Entity to return, with `_revision` when relevant.
97
101
  */
98
102
  export async function readWrittenRevision({
99
103
  backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
100
- resourceVersion,
104
+ resourceVersion, langcode,
101
105
  }) {
106
+ // Sentinel returned the requested translation; a canonical read would replace
107
+ // it with live English, and the core working-copy alias may not address it.
108
+ if (langcode && patchResult) return patchResult;
102
109
  if (!relationshipsSent) {
103
110
  if (preferCanonical && typeof backend.getEntity === "function") {
104
111
  const fresh = await backend.getEntity({
@@ -136,6 +136,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
136
136
  const written = await readWrittenRevision({
137
137
  backend, entityType, bundle, id,
138
138
  relationshipsSent: relationshipsWereSent(resolvedRelationships),
139
+ langcode,
139
140
  patchResult: result,
140
141
  preferCanonical: false,
141
142
  });
@@ -469,16 +469,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
469
469
  let fresh = await readWrittenRevision({
470
470
  backend, entityType: "node", bundle: type, id,
471
471
  relationshipsSent: relationshipsWereSent(resolvedRelationships),
472
+ langcode,
472
473
  patchResult: patched,
473
474
  preferCanonical: true,
474
475
  resourceVersion: patchTarget.resourceVersion,
475
476
  });
476
477
  const intendedAlias = pathAttr?.alias ? normalizeAlias(pathAttr.alias) : null;
477
478
  if (intendedAlias) {
478
- fresh = await assertAliasPersisted({
479
+ const aliasChecked = await assertAliasPersisted({
479
480
  backend, sec, type, id, intendedAlias,
480
481
  resourceVersion: patchTarget.resourceVersion, written: fresh,
481
482
  });
483
+ fresh = langcode ? { ...fresh, url: aliasChecked.url } : aliasChecked;
482
484
  }
483
485
  // Redirect only after the new alias is what re-reads show (#274).
484
486
  const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
@@ -582,7 +584,7 @@ export const definitions = [
582
584
  },
583
585
  {
584
586
  name: "drupal_update_node",
585
- description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); dryRun uses that same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
587
+ 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.",
586
588
  inputSchema: {
587
589
  type: "object", required: ["type", "id"],
588
590
  properties: {
@@ -23,6 +23,7 @@ import { resolveBackend } from "../lib/backends/index.js";
23
23
  import {
24
24
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
25
25
  } from "../lib/security.js";
26
+ import { readNodeDraftInventory } from "../lib/node-draft-inventory.js";
26
27
  import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
27
28
  import { omitLiveComputedMetatag } from "../lib/entity-response.js";
28
29
 
@@ -177,13 +178,28 @@ async function listRevisions({ site: siteName, type, id }) {
177
178
 
178
179
  // The working-copy alias only resolves on entities under a content_moderation
179
180
  // workflow with a pending forward revision; absence is expected, not an error.
180
- const workingCopy = await fetchRevisionResource(backend, "node", type, id, "rel:working-copy")
181
+ let workingCopy = await fetchRevisionResource(backend, "node", type, id, "rel:working-copy")
181
182
  .then(summarizeRevision)
182
183
  .catch(() => null);
183
184
 
185
+ if (!workingCopy || String(workingCopy.vid) === String(latestVersion?.vid)) {
186
+ const inventory = await readNodeDraftInventory(backend, { entityType: "node", bundle: type, id });
187
+ if (inventory?.working && String(inventory.working.vid) !== String(inventory.live.vid)) {
188
+ if (latestVersion && String(latestVersion.vid) !== String(inventory.live.vid)) {
189
+ throw new Error("The live revision changed during discovery. Re-read the revision inventory.");
190
+ }
191
+ // No aggregate publication flag: English may be published while Spanish is draft.
192
+ workingCopy = { vid: Number(inventory.working.vid), source: "sentinel-inventory",
193
+ translations: inventory.working.translations.map(({ langcode, status, moderation_state, default: isDefault }) => ({
194
+ langcode, status, moderation_state, default: isDefault,
195
+ })) };
196
+ }
197
+ }
198
+
184
199
  const possiblyPatchBlocked = Boolean(latestVersion && changedAheadOfRevision(latestVersion));
185
200
  let note = LIST_REVISIONS_BASE_NOTE;
186
201
  if (!workingCopy) note += LIST_REVISIONS_NULL_WC_NOTE;
202
+ if (workingCopy?.source === "sentinel-inventory") note += " Working revision discovered through Sentinel; publication state is language-specific. Continue an unpublished translation with explicit langcode.";
187
203
  if (possiblyPatchBlocked) note += LIST_REVISIONS_FINGERPRINT_NOTE;
188
204
 
189
205
  return {
@@ -332,7 +348,7 @@ export const definitions = [
332
348
  {
333
349
  name: "drupal_list_revisions",
334
350
  description:
335
- "Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.",
351
+ "Surface the latest default and working node revisions. When the core working-copy alias is absent or echoes live, consult Sentinel translation inventory; workingCopy then includes source and language-specific translations instead of a misleading aggregate status. Continue an unpublished translation with explicit langcode. workingCopy: null is not an all-clear — Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.",
336
352
  inputSchema: {
337
353
  type: "object", required: ["type", "id"],
338
354
  properties: {