drupal-mcp-connector 2.23.0 → 2.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  ---
2
- description: "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,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.23.1] - 2026-09-23
11
+
12
+ ### Fixed
13
+ - **Default-language draft on a multilingual node (#379).** `drupal_update_node`
14
+ and the other update tools without `langcode` no longer stop at Sentinel's
15
+ 409 when the working revision holds more than one language. When the default
16
+ language is the unpublished draft in that revision, the connector names it in
17
+ `X-MCP-Draft-Langcode`, on the preflight and on the write. It reads the
18
+ translation inventory only after Sentinel asks for the language, so other
19
+ writes make no extra request. A translation-only draft over published default
20
+ copy still needs an explicit `langcode`. When an older Sentinel refuses a
21
+ shared-field or paragraph change as a translation write, the message names
22
+ MCP Sentinel 2.24.2.
23
+
10
24
  ## [2.23.0] - 2026-09-23
11
25
 
12
26
  ### 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.1",
4
4
  "description": "Drupal MCP Connector — multi-site MCP server for Drupal with JSON:API and GraphQL, governed writes, draft translations, content tools, audit reports, and an SSH Drush bridge.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -25,7 +25,13 @@
25
25
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
26
26
  import { entityRevisionId } from "./write-revision.js";
27
27
  import { httpStatusOf } from "./error-status.js";
28
- import { writeDraft, readNodeDraftInventory, assertInventoryDraftLanguage } from "./sentinel-draft.js";
28
+ import {
29
+ writeDraft,
30
+ readNodeDraftInventory,
31
+ assertInventoryDraftLanguage,
32
+ inferDefaultDraftLangcode,
33
+ MIN_SENTINEL_DEFAULT_LANGUAGE_DRAFT_VERSION,
34
+ } from "./sentinel-draft.js";
29
35
  import { PREFLIGHT_NONE, PREFLIGHT_CORE_GUARD, PREFLIGHT_SENTINEL_DRAFT } from "./dry-run-checks.js";
30
36
 
31
37
  /** Stable error code for a core working-copy / not-latest-revision block. */
@@ -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: {