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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
475
|
-
entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
|
|
476
|
-
}, true);
|
|
491
|
+
await preflightDraft(inferredLangcode);
|
|
477
492
|
} catch (err) {
|
|
478
|
-
|
|
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
|
|
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.
|
package/src/tools/nodes.js
CHANGED
|
@@ -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: {
|