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.
|
|
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",
|
package/src/lib/http-auth.js
CHANGED
|
@@ -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
|
|
612
|
-
const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri), {
|
|
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 {
|
|
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: {
|
|
@@ -97,16 +97,15 @@ async function listTranslations({ site: siteName, entityType = "node", type, id
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
const
|
|
101
|
-
|
|
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 =
|
|
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:
|
|
108
|
+
id: entity.id,
|
|
110
109
|
entityType,
|
|
111
110
|
bundle: type,
|
|
112
111
|
defaultLangcode,
|