drupal-mcp-connector 3.1.0 → 3.2.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.
@@ -18,7 +18,7 @@ const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload_",
18
18
  "drupal_drush_updatedb", "drupal_drush_module_enable",
19
19
  "drupal_drush_module_disable", "drupal_drush_user_create",
20
20
  // v1.0 feature tools that perform writes but don't start with create_/update_:
21
- "drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_",
21
+ "drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_", "drupal_draft_",
22
22
  // Governed config write (also gated inside the handler by the config-write cap):
23
23
  "drupal_config_set"];
24
24
 
@@ -24,6 +24,12 @@
24
24
 
25
25
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
26
26
  import { entityRevisionId } from "./write-revision.js";
27
+ import {
28
+ isCanonicalDraftType,
29
+ preflightGovernedCanonicalDraft,
30
+ resolveCanonicalDraftIdentity,
31
+ writeCanonicalModeratedEntity,
32
+ } from "./canonical-draft.js";
27
33
  import { httpStatusOf } from "./error-status.js";
28
34
  import {
29
35
  writeDraft,
@@ -245,6 +251,20 @@ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
245
251
  * @throws {PatchTargetAmbiguousError}
246
252
  */
247
253
  export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
254
+ if (isCanonicalDraftType(entityType)) {
255
+ // A readable unmoderated block or library item has no draft alias to
256
+ // prove. Identity resolution stays fail-closed when the entity is
257
+ // moderated or could not be read.
258
+ if (existing && !entityLooksModerated(existing)) {
259
+ return {
260
+ resourceVersion: undefined,
261
+ workingCopy: null,
262
+ liveVid: entityRevisionId(existing, entityType),
263
+ workingVid: null,
264
+ };
265
+ }
266
+ return resolveCanonicalDraftIdentity(backend, { entityType, bundle, id, existing });
267
+ }
248
268
  const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
249
269
  if (workingCopy?.id && workingCopy.id !== id) {
250
270
  throw new PatchTargetAmbiguousError(id, workingCopy.id);
@@ -478,6 +498,17 @@ export async function prepareGuardedPatch(backend, {
478
498
  target.preflight = PREFLIGHT_SENTINEL_DRAFT;
479
499
  return target;
480
500
  }
501
+ if (target.governedDraft) {
502
+ try {
503
+ await preflightGovernedCanonicalDraft(backend, {
504
+ entityType, bundle, id, attributes, relationships, governedDraft: target.governedDraft,
505
+ });
506
+ } catch (err) {
507
+ throw rewriteStaleCopyError(err);
508
+ }
509
+ target.preflight = PREFLIGHT_SENTINEL_DRAFT;
510
+ return target;
511
+ }
481
512
  if (target.resourceVersion) {
482
513
  const draftRevisionFor = (lang) => ({
483
514
  liveVid: target.liveVid,
@@ -584,6 +615,9 @@ function explainDefaultLanguageRefusal(err, inferredLangcode) {
584
615
  * @throws {PatchBlockedError|WorkingCopyStaleError|*}
585
616
  */
586
617
  export async function updateEntityGuarded(backend, input) {
618
+ if (isCanonicalDraftType(input?.entityType) && !input?.draftRevision && !input?.resourceVersion) {
619
+ return writeCanonicalModeratedEntity(backend, input);
620
+ }
587
621
  try {
588
622
  if (input?.draftRevision) return await writeDraft(backend, input);
589
623
  if (input?.resourceVersion) {
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Text-format restrictions from MCP Sentinel's site schema (#429).
3
+ *
4
+ * A content-tier token cannot read JSON:API `field_config` (that needs
5
+ * "administer node fields") and this connector's production configs often
6
+ * have no Drush bridge. Sentinel's `GET /drupal-mcp/context` is the schema
7
+ * that token can read. When a field entry includes `allowed_formats`, that
8
+ * list is Field API's restriction: one entry is the default, any other
9
+ * format is refused. A missing key means an older Sentinel that does not
10
+ * report the restriction — the caller must not treat that as "all formats".
11
+ * An empty array means the field has no format restriction.
12
+ *
13
+ * Dispatch fills the process cache before a tool that resolves text formats.
14
+ * A failed fetch is remembered only briefly so a Sentinel outage does not
15
+ * add the timeout to every later write, and concurrent writes share one
16
+ * request. Unit tests that call handlers directly can seed a successful
17
+ * document with `rememberTextFormatContext`.
18
+ */
19
+
20
+ import { authHeadersAsync, clientHeaders } from "./config.js";
21
+
22
+ /** How long a successful context document is reused. */
23
+ const CONTEXT_TTL_MS = 10 * 60 * 1000;
24
+
25
+ /** How long a miss is remembered. Short, so a recovered Sentinel is retried. */
26
+ const FAILURE_TTL_MS = 30 * 1000;
27
+
28
+ /** Context fetch budget. A miss must not stall the write past this. */
29
+ const CONTEXT_TIMEOUT_MS = 8000;
30
+
31
+ /**
32
+ * Tools whose handlers call the text-format resolver. Other writes must not
33
+ * wait on this document.
34
+ */
35
+ const TEXT_FORMAT_TOOLS = new Set([
36
+ "drupal_create_node",
37
+ "drupal_update_node",
38
+ "drupal_entity_create",
39
+ "drupal_entity_update",
40
+ "drupal_create_translation",
41
+ "drupal_bulk_create",
42
+ "drupal_bulk_update",
43
+ "drupal_create_paragraph",
44
+ "drupal_update_paragraph",
45
+ "drupal_create_media",
46
+ "drupal_update_media",
47
+ "drupal_draft_nested_components",
48
+ ]);
49
+
50
+ /** @type {Map<string, {at: number, document: ?object, ttl: number}>} */
51
+ const cache = new Map();
52
+
53
+ /** @type {Map<string, {slot: object, promise: Promise<void>}>} */
54
+ const inflight = new Map();
55
+
56
+ /** @type {(site: object) => Promise<?object>} */
57
+ let fetcher = async () => null;
58
+
59
+ /**
60
+ * Cache key for one configured site. Name and base URL both matter so two
61
+ * sites that share a name in different processes cannot collide here, and a
62
+ * renamed base URL does not reuse the previous document.
63
+ * @param {object} site Resolved site config.
64
+ * @returns {string}
65
+ */
66
+ function cacheKey(site) {
67
+ return `${site?._name ?? ""}\0${site?.baseUrl ?? ""}`;
68
+ }
69
+
70
+ /**
71
+ * Drop cached schema documents. Tests call this between cases.
72
+ * @returns {void}
73
+ */
74
+ export function clearTextFormatContextCache() {
75
+ cache.clear();
76
+ inflight.clear();
77
+ }
78
+
79
+ /**
80
+ * Whether this tool resolves text formats and should wait for context.
81
+ * @param {string} toolName
82
+ * @returns {boolean}
83
+ */
84
+ export function toolNeedsTextFormatContext(toolName) {
85
+ return TEXT_FORMAT_TOOLS.has(toolName);
86
+ }
87
+
88
+ /**
89
+ * Seed the cache without a network call. A seeded document uses the success TTL.
90
+ * @param {object} site Resolved site config.
91
+ * @param {object} document Sentinel context JSON.
92
+ * @returns {void}
93
+ */
94
+ export function rememberTextFormatContext(site, document) {
95
+ cache.set(cacheKey(site), { at: Date.now(), document, ttl: CONTEXT_TTL_MS });
96
+ }
97
+
98
+ /**
99
+ * @param {string} key
100
+ * @returns {boolean}
101
+ */
102
+ function fresh(key) {
103
+ const hit = cache.get(key);
104
+ if (!hit) return false;
105
+ return Date.now() - hit.at < hit.ttl;
106
+ }
107
+
108
+ /**
109
+ * Install the process fetcher. The server entry calls this with
110
+ * {@link fetchTextFormatContext}. Tests leave the default, which returns
111
+ * null and does not touch the network.
112
+ * @param {(site: object) => Promise<?object>} next
113
+ * @returns {void}
114
+ */
115
+ export function installTextFormatContextFetcher(next) {
116
+ fetcher = next;
117
+ }
118
+
119
+ /**
120
+ * Load `GET /drupal-mcp/context` when the cache is cold. A failure is cached
121
+ * as a miss for 30 seconds: the write keeps the historical path
122
+ * for an unknown format list, and the next write does not wait again.
123
+ * Concurrent callers share one in-flight request.
124
+ * @param {object} site Resolved site config.
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function ensureTextFormatContext(site) {
128
+ if (!site?.baseUrl) return;
129
+ const key = cacheKey(site);
130
+ if (fresh(key)) return;
131
+ const pending = inflight.get(key);
132
+ if (pending) {
133
+ await pending.promise;
134
+ return;
135
+ }
136
+ // Identity is a plain object. Comparing the promise itself is what CodeQL
137
+ // reports as a missing await.
138
+ const slot = {};
139
+ const promise = loadTextFormatContext(site, key);
140
+ inflight.set(key, { slot, promise });
141
+ try {
142
+ await promise;
143
+ } finally {
144
+ const row = inflight.get(key);
145
+ if (row?.slot === slot) inflight.delete(key);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * @param {object} site
151
+ * @param {string} key
152
+ * @returns {Promise<void>}
153
+ */
154
+ async function loadTextFormatContext(site, key) {
155
+ let document = null;
156
+ try {
157
+ const loaded = await fetcher(site);
158
+ if (loaded && typeof loaded === "object" && !Array.isArray(loaded)) document = loaded;
159
+ } catch {
160
+ document = null;
161
+ }
162
+ cache.set(key, {
163
+ at: Date.now(),
164
+ document,
165
+ ttl: document ? CONTEXT_TTL_MS : FAILURE_TTL_MS,
166
+ });
167
+ }
168
+
169
+ /**
170
+ * GET /drupal-mcp/context as the site's own principal.
171
+ * @param {object} site Resolved site config.
172
+ * @returns {Promise<?object>} The JSON document, or null on any miss.
173
+ */
174
+ export async function fetchTextFormatContext(site) {
175
+ let headers;
176
+ try {
177
+ headers = {
178
+ Accept: "application/json",
179
+ ...clientHeaders(),
180
+ ...(await authHeadersAsync(site)),
181
+ };
182
+ } catch {
183
+ return null;
184
+ }
185
+ let res;
186
+ try {
187
+ res = await fetch(`${site.baseUrl}/drupal-mcp/context`, {
188
+ method: "GET",
189
+ headers,
190
+ signal: AbortSignal.timeout(CONTEXT_TIMEOUT_MS),
191
+ });
192
+ } catch {
193
+ return null;
194
+ }
195
+ if (!res.ok) return null;
196
+ try {
197
+ const body = await res.json();
198
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
199
+ if (!Object.prototype.hasOwnProperty.call(body, "content_types")) return null;
200
+ return body;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Field definition from a cached context document.
208
+ * Only node bundles are present. A field with no `allowed_formats` key is
209
+ * unknown (older Sentinel), not unrestricted.
210
+ * @param {object} site Resolved site config.
211
+ * @param {string} entityType
212
+ * @param {string} bundle
213
+ * @param {string} fieldName
214
+ * @returns {?{fieldName: string, fieldType: ?string, allowedFormats: string[]}}
215
+ */
216
+ export function textFormatDefinitionFromContext(site, entityType, bundle, fieldName) {
217
+ if (entityType !== "node") return null;
218
+ const hit = cache.get(cacheKey(site));
219
+ // A cached miss (document null) is not a schema. Do not invent a list.
220
+ if (!hit?.document || !fresh(cacheKey(site))) return null;
221
+ const types = hit.document?.content_types;
222
+ if (!types || typeof types !== "object" || Array.isArray(types)) return null;
223
+ const schema = new Map(Object.entries(types)).get(bundle);
224
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return null;
225
+ const fields = new Map(Object.entries(schema)).get("fields");
226
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) return null;
227
+ const field = new Map(Object.entries(fields)).get(fieldName);
228
+ if (!field || typeof field !== "object" || Array.isArray(field)) return null;
229
+ const attrs = new Map(Object.entries(field));
230
+ if (!attrs.has("allowed_formats")) return null;
231
+ const fieldType = attrs.get("type");
232
+ return {
233
+ fieldName,
234
+ fieldType: typeof fieldType === "string" ? fieldType : null,
235
+ allowedFormats: normalizeContextFormats(attrs.get("allowed_formats")),
236
+ };
237
+ }
238
+
239
+ /**
240
+ * @param {*} raw `allowed_formats` from the context document.
241
+ * @returns {string[]}
242
+ */
243
+ function normalizeContextFormats(raw) {
244
+ const values = Array.isArray(raw)
245
+ ? raw
246
+ : (raw && typeof raw === "object" ? [...new Map(Object.entries(raw)).values()] : []);
247
+ return values.filter((value) => typeof value === "string" && value !== "" && value !== "0");
248
+ }
@@ -11,27 +11,73 @@
11
11
  */
12
12
 
13
13
  /**
14
- * Read a revision id off a canonical (or raw-ish) entity body.
15
- * @param {?object} entity
14
+ * JSON:API revision attribute for entity types whose revision key is known.
15
+ * `taxonomy_term`'s `drupal_internal__vid` is the vocabulary id, not a revision.
16
+ * Node and media revisions use `vid`. Paragraphs, library items, and custom
17
+ * blocks use `revision_id`.
18
+ */
19
+ const REVISION_ATTRIBUTE = {
20
+ node: "drupal_internal__vid",
21
+ media: "drupal_internal__vid",
22
+ paragraph: "drupal_internal__revision_id",
23
+ paragraphs_library_item: "drupal_internal__revision_id",
24
+ block_content: "drupal_internal__revision_id",
25
+ };
26
+
27
+ /**
28
+ * @param {object} entity
29
+ * @param {object} fields
30
+ * @param {object} attrs
31
+ * @param {string} key
32
+ * @returns {unknown}
33
+ */
34
+ function readRevisionAttribute(entity, fields, attrs, key) {
35
+ if (key === "drupal_internal__vid") {
36
+ return entity.vid
37
+ ?? fields.drupal_internal__vid
38
+ ?? attrs.drupal_internal__vid
39
+ ?? entity.drupal_internal__vid;
40
+ }
41
+ if (Object.prototype.hasOwnProperty.call(fields, key)) return fields[key];
42
+ if (Object.prototype.hasOwnProperty.call(attrs, key)) return attrs[key];
43
+ if (Object.prototype.hasOwnProperty.call(entity, key)) return entity[key];
44
+ return undefined;
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} raw
16
49
  * @returns {?number|string}
17
50
  */
18
- export function entityRevisionId(entity) {
19
- if (!entity || typeof entity !== "object") return null;
20
- const fields = entity.fields && typeof entity.fields === "object"
21
- ? entity.fields
22
- : {};
23
- const attrs = entity.attributes && typeof entity.attributes === "object"
24
- ? entity.attributes
25
- : {};
26
- const raw = entity.vid
27
- ?? fields.drupal_internal__vid
28
- ?? attrs.drupal_internal__vid
29
- ?? entity.drupal_internal__vid;
51
+ function normalizeRevisionId(raw) {
30
52
  if (raw === undefined || raw === null || raw === "") return null;
31
53
  const n = Number(raw);
32
54
  return Number.isFinite(n) ? n : raw;
33
55
  }
34
56
 
57
+ /**
58
+ * Read a revision id off a canonical (or raw-ish) entity body.
59
+ * When `entityType` is known, only that type's revision attribute is read.
60
+ * When it is omitted, a node-style vid is kept and `revision_id` is used
61
+ * only if no vid is present, so existing callers stay unchanged.
62
+ *
63
+ * @param {?object} entity
64
+ * @param {?string} [entityType]
65
+ * @returns {?number|string}
66
+ */
67
+ export function entityRevisionId(entity, entityType) {
68
+ if (!entity || typeof entity !== "object") return null;
69
+ const type = entityType || entity.entityType || null;
70
+ const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : {};
71
+ const attrs = entity.attributes && typeof entity.attributes === "object" ? entity.attributes : {};
72
+ if (type === "taxonomy_term") return null;
73
+ if (type && REVISION_ATTRIBUTE[type]) {
74
+ return normalizeRevisionId(readRevisionAttribute(entity, fields, attrs, REVISION_ATTRIBUTE[type]));
75
+ }
76
+ const vid = readRevisionAttribute(entity, fields, attrs, "drupal_internal__vid");
77
+ if (vid !== undefined && vid !== null && vid !== "") return normalizeRevisionId(vid);
78
+ return normalizeRevisionId(readRevisionAttribute(entity, fields, attrs, "drupal_internal__revision_id"));
79
+ }
80
+
35
81
  /**
36
82
  * Attach distinct live vs working revision ids when both are known.
37
83
  * Does not invent vids.
@@ -65,7 +111,8 @@ export async function attachWrittenRevisionPair({
65
111
  backend, entityType, bundle, id, entity, liveVid,
66
112
  }) {
67
113
  if (liveVid === null || liveVid === undefined || !entity) return entity;
68
- let workingVid = entityRevisionId(entity);
114
+ if (entity._revisions) return entity;
115
+ let workingVid = entityRevisionId(entity, entityType);
69
116
  if (workingVid !== null && String(workingVid) !== String(liveVid)) {
70
117
  return attachRevisionPair(entity, { live: liveVid, working: workingVid });
71
118
  }
@@ -74,7 +121,7 @@ export async function attachWrittenRevisionPair({
74
121
  const wc = await backend.getEntity({
75
122
  entityType, bundle, id, resourceVersion: "rel:working-copy",
76
123
  });
77
- workingVid = entityRevisionId(wc);
124
+ workingVid = entityRevisionId(wc, entityType);
78
125
  } catch {
79
126
  workingVid = null;
80
127
  }
@@ -106,6 +153,9 @@ export async function readWrittenRevision({
106
153
  // Sentinel returned the requested translation; a canonical read would replace
107
154
  // it with live English, and the core working-copy alias may not address it.
108
155
  if (langcode && patchResult) return patchResult;
156
+ if (patchResult?._revision?.source === "working-copy" || patchResult?._revision?.source === "latest-version") {
157
+ return patchResult;
158
+ }
109
159
  if (!relationshipsSent) {
110
160
  if (preferCanonical && typeof backend.getEntity === "function") {
111
161
  const fresh = await backend.getEntity({
package/src/tools/bulk.js CHANGED
@@ -14,12 +14,14 @@
14
14
  import { getSiteConfig } from "../lib/config.js";
15
15
  import { resolveBackend } from "../lib/backends/index.js";
16
16
  import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed, withUnpublishedDefault } from "../lib/security.js";
17
- import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
17
+ import { applySafeDraftDefault } from "../lib/moderation-default.js";
18
+ import { readUpdateTarget } from "../lib/canonical-draft.js";
18
19
  import {
19
20
  resolveErrRelationships, embedParagraphRef,
20
21
  resolveParagraphRevisionId, missingParagraphRevisionError,
21
22
  } from "../lib/err-relationships.js";
22
23
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
24
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
23
25
 
24
26
  /**
25
27
  * Normalize an unknown thrown value into a human-readable message.
@@ -52,11 +54,15 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
52
54
  for (const [index, rawItem] of items.entries()) {
53
55
  const item = rawItem || {};
54
56
  try {
55
- assertPublishAllowed(sec, item.attributes ?? {});
57
+ const formatted = await applyAllowedFormatsToAttributes({
58
+ backend, site, entityType, bundle, attributes: { ...(item.attributes ?? {}) },
59
+ defaultBodyFormat: false,
60
+ });
61
+ assertPublishAllowed(sec, formatted);
56
62
  const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
57
63
  const entity = await backend.createEntity({
58
64
  entityType, bundle,
59
- attributes: withUnpublishedDefault(sec, entityType, item.attributes ?? {}),
65
+ attributes: withUnpublishedDefault(sec, entityType, formatted),
60
66
  relationships: resolvedRelationships,
61
67
  });
62
68
  created += 1;
@@ -103,17 +109,16 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
103
109
  const item = rawItem || {};
104
110
  try {
105
111
  if (!item.id) throw new Error("Missing 'id' for update item");
106
- let existing = null;
107
- if (!hasExplicitModerationState(item.attributes ?? {})) {
108
- try {
109
- existing = (await backend.getEntity({ entityType, bundle, id: item.id })) ?? null;
110
- } catch {
111
- existing = null;
112
- }
113
- }
112
+ const existing = await readUpdateTarget(backend, {
113
+ entityType, bundle, id: item.id, attributes: item.attributes ?? {},
114
+ });
115
+ const formatted = await applyAllowedFormatsToAttributes({
116
+ backend, site, entityType, bundle, attributes: { ...(item.attributes ?? {}) },
117
+ existingEntity: existing, defaultBodyFormat: false,
118
+ });
114
119
  const attributes = await applySafeDraftDefault({
115
120
  backend, entityType, bundle, id: item.id,
116
- attributes: item.attributes ?? {},
121
+ attributes: formatted,
117
122
  existingEntity: existing,
118
123
  });
119
124
  assertPublishAllowed(sec, attributes);
@@ -11,13 +11,15 @@
11
11
  import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
13
  import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
14
- import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
14
+ import { applySafeDraftDefault } from "../lib/moderation-default.js";
15
15
  import {
16
16
  resolveErrRelationships, relationshipsWereSent, embedParagraphRef,
17
17
  resolveParagraphRevisionId, missingParagraphRevisionError,
18
18
  } from "../lib/err-relationships.js";
19
19
  import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
+ import { readUpdateTarget } from "../lib/canonical-draft.js";
22
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
21
23
  import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
22
24
  import {
23
25
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
@@ -69,9 +71,12 @@ async function createEntity({ site: siteName, entityType, bundle, attributes: ca
69
71
  const site = getSiteConfig(siteName);
70
72
  const sec = resolveSecurityConfig(site);
71
73
  assertWriteAllowed(sec, "create", entityType, bundle);
72
- assertPublishAllowed(sec, callerAttributes);
73
- const attributes = withUnpublishedDefault(sec, entityType, callerAttributes);
74
74
  const backend = await resolveBackend(site);
75
+ const formatted = await applyAllowedFormatsToAttributes({
76
+ backend, site, entityType, bundle, attributes: { ...callerAttributes }, defaultBodyFormat: false,
77
+ });
78
+ assertPublishAllowed(sec, formatted);
79
+ const attributes = withUnpublishedDefault(sec, entityType, formatted);
75
80
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
76
81
  if (dryRun) {
77
82
  return {
@@ -111,16 +116,13 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
111
116
  // One pre-write read serves both the #131 draft default and the #171
112
117
  // unrequested-status-change flag. Skipped when the caller pinned the
113
118
  // moderation state explicitly (same condition the draft default uses).
114
- let existing = null;
115
- if (!hasExplicitModerationState(attributes)) {
116
- try {
117
- existing = (await backend.getEntity({ entityType, bundle, id })) ?? null;
118
- } catch {
119
- existing = null; // Unreadable target: server-side gates stay authoritative.
120
- }
121
- }
119
+ const existing = await readUpdateTarget(backend, { entityType, bundle, id, attributes });
120
+ const formatted = await applyAllowedFormatsToAttributes({
121
+ backend, site, entityType, bundle, attributes: { ...attributes }, existingEntity: existing,
122
+ defaultBodyFormat: false,
123
+ });
122
124
  const safeAttributes = await applySafeDraftDefault({
123
- backend, entityType, bundle, id, attributes, existingEntity: existing,
125
+ backend, entityType, bundle, id, attributes: formatted, existingEntity: existing,
124
126
  });
125
127
  assertPublishAllowed(sec, safeAttributes);
126
128
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
@@ -297,7 +299,7 @@ export const definitions = [
297
299
  },
298
300
  {
299
301
  name: "drupal_entity_update",
300
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight 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.",
302
+ description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight 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. When the draft inventory advertises open_draft, a published moderated paragraphs_library_item or block_content opens and continues through /mcp-draft with If-Match live or live:working, no mcp_components, and a re-read of the published pins. A continuation compares the forward revision with the pre-write working pins. When open_draft is not advertised, a published moderated paragraphs_library_item or block_content with no forward revision is saved by that canonical draft PATCH, then the published revision, rel:working-copy, and paragraph pins (including order) are re-read. The result fails if the published revision changed or no forward revision appeared. An existing forward draft, a missing revision id, or a denied version read is refused before any write. An unmoderated item keeps the ordinary update. A completed 4xx is returned when that re-read matches the pre-write revision and pins; a lost response stays uncertain (#420).",
301
303
  inputSchema: {
302
304
  type: "object", required: ["entityType", "bundle", "id"],
303
305
  properties: {
@@ -308,7 +310,7 @@ export const definitions = [
308
310
  langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
309
311
  attributes: { type: "object" },
310
312
  relationships: { type: "object" },
311
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
313
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. An existing node draft (or a langcode translation draft), and a paragraphs_library_item or block_content whose inventory advertises open_draft, are checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
312
314
  returning: RETURNING_SCHEMA,
313
315
  },
314
316
  },
@@ -29,6 +29,7 @@ import * as references from "./references.js";
29
29
  import * as bulk from "./bulk.js";
30
30
  import * as translations from "./translations.js";
31
31
  import * as paragraphs from "./paragraphs.js";
32
+ import * as nested from "./nested.js";
32
33
  import * as structure from "./structure.js";
33
34
  import * as redirects from "./redirects.js";
34
35
  import * as search from "./search.js";
@@ -42,7 +43,7 @@ import * as codegen from "./codegen.js";
42
43
  import { SITE_PARAM } from "../lib/site-target.js";
43
44
 
44
45
  const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
45
- revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
46
+ revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, nested, structure, redirects, search, reportsExtra,
46
47
  reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
47
48
 
48
49
  /**
@@ -19,6 +19,7 @@ import {
19
19
  } from "../lib/sentinel-draft.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
21
  import { entityRevisionId } from "../lib/write-revision.js";
22
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
22
23
 
23
24
  /**
24
25
  * List all media types (bundles of the media entity type).
@@ -110,8 +111,11 @@ async function createMedia({ site: siteName, type, name, status = false, fields
110
111
  // Layer name/status after fields so they win, matching prior behaviour.
111
112
  attributes.name = name;
112
113
  attributes.status = status;
113
- assertPublishAllowed(sec, attributes);
114
114
  const backend = await resolveBackend(site);
115
+ await applyAllowedFormatsToAttributes({
116
+ backend, site, entityType: "media", bundle: type, attributes, defaultBodyFormat: false,
117
+ });
118
+ assertPublishAllowed(sec, attributes);
115
119
  return backend.createEntity({
116
120
  entityType: "media", bundle: type, attributes,
117
121
  ...(relationships ? { relationships } : {}),
@@ -134,8 +138,11 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
134
138
  const { attributes, relationships } = splitReferenceFields(fields);
135
139
  if (name !== undefined) attributes.name = name;
136
140
  if (status !== undefined) attributes.status = status;
137
- assertPublishAllowed(sec, attributes);
138
141
  const backend = await resolveBackend(site);
142
+ await applyAllowedFormatsToAttributes({
143
+ backend, site, entityType: "media", bundle: type, attributes, defaultBodyFormat: false,
144
+ });
145
+ assertPublishAllowed(sec, attributes);
139
146
  if (langcode) {
140
147
  const targetLang = assertDraftLangcode(langcode);
141
148
  const patchTarget = await prepareGuardedPatch(backend, {