drupal-mcp-connector 2.23.0 → 2.23.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: "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."
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. 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.
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,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.2] - 2026-09-28
11
+
12
+ ### Fixed
13
+ - **Adapter-neutral `drupal_list_translations` fallback.** When Sentinel's
14
+ translation inventory is unavailable, the tool reads `langcode` from
15
+ `backend.getEntity` instead of a JSON:API `rawQuery` path. GraphQL
16
+ backends can report the one observable language; `FALLBACK_NOTE` is
17
+ unchanged.
18
+ - **Inbound IdP HTTP timeouts.** RFC 8414 / OIDC discovery, JWKS retrieval,
19
+ and RFC 7662 introspection abort after 30s (`AbortSignal.timeout`), matching
20
+ outbound Drupal HTTP. A hung issuer fails closed (discovery throw on HTTPS
21
+ bring-up; 401 on `/mcp` auth) instead of stalling the process.
22
+
23
+ ## [2.23.1] - 2026-09-23
24
+
25
+ ### Fixed
26
+ - **Default-language draft on a multilingual node (#379).** `drupal_update_node`
27
+ and the other update tools without `langcode` no longer stop at Sentinel's
28
+ 409 when the working revision holds more than one language. When the default
29
+ language is the unpublished draft in that revision, the connector names it in
30
+ `X-MCP-Draft-Langcode`, on the preflight and on the write. It reads the
31
+ translation inventory only after Sentinel asks for the language, so other
32
+ writes make no extra request. A translation-only draft over published default
33
+ copy still needs an explicit `langcode`. When an older Sentinel refuses a
34
+ shared-field or paragraph change as a translation write, the message names
35
+ MCP Sentinel 2.24.2.
36
+
10
37
  ## [2.23.0] - 2026-09-23
11
38
 
12
39
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.23.0",
3
+ "version": "2.23.2",
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",
@@ -6,13 +6,29 @@
6
6
  * - OAuth resource server (`createResourceAuthenticator`) — network-facing
7
7
  * HTTPS. JWT via issuer discovery + JWKS, optional RFC 7662 introspection,
8
8
  * and a hot-reloaded revocation file. Caller-supplied identity headers
9
- * never become the principal.
9
+ * never become the principal. IdP HTTP (discovery, JWKS, introspection)
10
+ * aborts after `INBOUND_IDP_TIMEOUT_MS` (30s).
10
11
  */
11
12
 
12
13
  import { timingSafeEqual } from "crypto";
13
14
  import { readFileSync, statSync, writeFileSync } from "fs";
14
15
  import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
15
16
 
17
+ /** Inbound IdP HTTP timeout. Matches Drupal/Drush outbound HTTP. */
18
+ export const INBOUND_IDP_TIMEOUT_MS = 30_000;
19
+
20
+ /**
21
+ * Attach the inbound IdP abort timeout. A caller `signal` wins.
22
+ * @param {RequestInit} [options]
23
+ * @returns {RequestInit}
24
+ */
25
+ function withInboundTimeout(options = {}) {
26
+ return {
27
+ ...options,
28
+ signal: options.signal ?? AbortSignal.timeout(INBOUND_IDP_TIMEOUT_MS),
29
+ };
30
+ }
31
+
16
32
  /** Header names that must never become identity. */
17
33
  export const SPOOFABLE_IDENTITY_HEADERS = Object.freeze([
18
34
  "x-mcp-subject",
@@ -361,7 +377,7 @@ export async function discoverAuthorizationServer(issuer, fetchFn = fetch) {
361
377
  for (const url of authorizationServerDiscoveryUrls(issuer)) {
362
378
  let res;
363
379
  try {
364
- res = await fetchFn(url, { headers: { accept: "application/json" } });
380
+ res = await fetchFn(url, withInboundTimeout({ headers: { accept: "application/json" } }));
365
381
  } catch {
366
382
  continue;
367
383
  }
@@ -393,7 +409,7 @@ export async function introspectToken(token, {
393
409
  fetchFn = fetch,
394
410
  }) {
395
411
  const creds = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
396
- const res = await fetchFn(url, {
412
+ const res = await fetchFn(url, withInboundTimeout({
397
413
  method: "POST",
398
414
  headers: {
399
415
  authorization: `Basic ${creds}`,
@@ -401,7 +417,7 @@ export async function introspectToken(token, {
401
417
  accept: "application/json",
402
418
  },
403
419
  body: new URLSearchParams({ token }).toString(),
404
- });
420
+ }));
405
421
  if (!res.ok) return null;
406
422
  const body = await res.json();
407
423
  if (body.active !== true) return null;
@@ -608,8 +624,11 @@ export async function createInboundHttpsAuth({ inboundCfg, fetchFn = fetch }) {
608
624
  const asMeta = await discoverAuthorizationServer(issuer, fetchFn);
609
625
  const advertisedIssuer = asMeta.issuer || issuer;
610
626
  // JWKS retrieval goes through the same injectable fetch as issuer
611
- // discovery; with the default global fetch the behavior is unchanged.
612
- const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri), { [customFetch]: fetchFn });
627
+ // discovery, with jose's timeoutDuration aligned to the inbound budget.
628
+ const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri), {
629
+ timeoutDuration: INBOUND_IDP_TIMEOUT_MS,
630
+ [customFetch]: (url, options) => fetchFn(url, withInboundTimeout(options)),
631
+ });
613
632
  const resourceMetadataUrl = resourceMetadataUrlFor(resource);
614
633
  const revocationStore = inboundCfg.revocationFile
615
634
  ? createRevocationStore({ filePath: inboundCfg.revocationFile })
@@ -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. */
@@ -430,7 +436,11 @@ export async function prepareGuardedPatch(backend, {
430
436
  throw new StaleCopyError();
431
437
  }
432
438
  }
433
- if (!langcode && 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
+ }
434
444
  if (langcode) {
435
445
  const noWorking = !target.workingVid || !target.liveVid
436
446
  || String(target.workingVid) === String(target.liveVid);
@@ -469,14 +479,32 @@ export async function prepareGuardedPatch(backend, {
469
479
  return target;
470
480
  }
471
481
  if (target.resourceVersion) {
472
- 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);
473
490
  try {
474
- await writeDraft(backend, {
475
- entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
476
- }, true);
491
+ await preflightDraft(inferredLangcode);
477
492
  } catch (err) {
478
- 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
+ }
479
506
  }
507
+ target.draftRevision = draftRevisionFor(inferredLangcode);
480
508
  target.preflight = PREFLIGHT_SENTINEL_DRAFT;
481
509
  return target;
482
510
  }
@@ -492,6 +520,60 @@ export async function prepareGuardedPatch(backend, {
492
520
  return target;
493
521
  }
494
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
+
495
577
  /**
496
578
  * `backend.updateEntity` with the core working-copy 400 rewritten.
497
579
  * A working-copy-targeted 400 is stale/concurrent (#166). A canonical 400
@@ -29,6 +29,8 @@ export const REVISE_PUBLISHED_TRANSLATION = "revise_published_translation";
29
29
  * Older hosts keep the create 409 and have no path in between.
30
30
  */
31
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";
32
34
 
33
35
  /**
34
36
  * Whether a translation inventory advertises published-translation revise.
@@ -209,7 +211,8 @@ export async function writeDraft(backend, input, preflight = false) {
209
211
  "If-Match": `"${live}:${working}"`,
210
212
  "X-MCP-Draft-Preflight": preflight ? "1" : "0",
211
213
  };
212
- const targetLang = langcode ? assertDraftLangcode(langcode) : null;
214
+ const requestedLang = langcode ?? draftRevision?.langcode;
215
+ const targetLang = requestedLang ? assertDraftLangcode(requestedLang) : null;
213
216
  if (targetLang) headers["X-MCP-Draft-Langcode"] = targetLang;
214
217
  const result = await sentinelDraftRequest(backend, {
215
218
  entityType, bundle, id, suffix: "mcp-draft", missingMessage: MISSING_DRAFT_ENDPOINT,
@@ -466,6 +469,27 @@ export async function readNodeDraftInventory(backend, ref) {
466
469
  return inventory;
467
470
  }
468
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
+
469
493
  /**
470
494
  * Ensure inventory discovery does not turn a published language into a draft.
471
495
  * Sentinel still validates the revision pair and language on every request.
@@ -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. 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.",
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: {
@@ -97,16 +97,15 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
97
97
  }
98
98
  }
99
99
 
100
- const res = await backend.rawQuery({ path: `/jsonapi/${entityType}/${type}/${id}` });
101
- const data = res?.data;
102
- if (!data) return null;
100
+ const entity = await backend.getEntity({ entityType, bundle: type, id });
101
+ if (!entity) return null;
103
102
 
104
- const defaultLangcode = data.attributes?.langcode ?? null;
103
+ const defaultLangcode = entity.langcode ?? null;
105
104
  const langcodes = defaultLangcode ? [defaultLangcode] : [];
106
105
  const translations = langcodes.map((lc) => ({ langcode: lc, default: lc === defaultLangcode }));
107
106
 
108
107
  return {
109
- id: data.id,
108
+ id: entity.id,
110
109
  entityType,
111
110
  bundle: type,
112
111
  defaultLangcode,