drupal-mcp-connector 2.14.2 → 2.15.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.
@@ -35,6 +35,9 @@ export const PATCH_WORKING_COPY_STALE_CODE = "PATCH_WORKING_COPY_STALE";
35
35
  /** Stable error code when the working-copy resource does not match the target. */
36
36
  export const PATCH_TARGET_AMBIGUOUS_CODE = "PATCH_TARGET_AMBIGUOUS";
37
37
 
38
+ /** Stable error code for Sentinel's save-time stale-default-revision refusal. */
39
+ export const STALE_COPY_CODE = "STALE_COPY";
40
+
38
41
  const WORKING_COPY_PATCH_RE = /has a working copy is not yet supported/i;
39
42
 
40
43
  /**
@@ -98,6 +101,81 @@ export class WorkingCopyStaleError extends Error {
98
101
  }
99
102
  }
100
103
 
104
+ /**
105
+ * Sentinel's save-time stale-default check (McpWritePreconditions) is not
106
+ * exercised by the id-mismatch PATCH probe — that probe fails before
107
+ * entity validation / presave. A published node with no distinct working
108
+ * copy and a changed timestamp later than its own revision_timestamp is
109
+ * the readable fingerprint (`possiblyPatchBlocked`). dryRun and the real
110
+ * write must refuse the same way; reloading and retrying does not help.
111
+ * Do not bypass the draft or publish gate. See connector #273.
112
+ */
113
+ export const STALE_COPY_MESSAGE =
114
+ "This entity cannot be updated: MCP Sentinel refused a stale default-revision " +
115
+ "write (the content changed after this copy was loaded). dryRun and the real " +
116
+ "write share this check. rel:latest-version and rel:working-copy report the " +
117
+ "same vid, but the default revision's changed timestamp is later than its " +
118
+ "revision_timestamp (possiblyPatchBlocked). Reloading and retrying the same " +
119
+ "canonical PATCH will not help. Do not bypass the draft or publish gate. " +
120
+ "See connector #273 / #201.";
121
+
122
+ const STALE_COPY_RE = /changed after this copy was loaded/i;
123
+
124
+ /**
125
+ * Thrown when a canonical write (or its dryRun) would hit Sentinel's
126
+ * stale-default-revision check (#273).
127
+ */
128
+ export class StaleCopyError extends Error {
129
+ /**
130
+ * @param {?Error} [cause]
131
+ */
132
+ constructor(cause) {
133
+ super(STALE_COPY_MESSAGE);
134
+ this.name = "StaleCopyError";
135
+ this.code = STALE_COPY_CODE;
136
+ if (cause) this.cause = cause;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Whether an error is Sentinel's stale-version refusal (or our rewrite).
142
+ * @param {unknown} err
143
+ * @returns {boolean}
144
+ */
145
+ export function isStaleCopyError(err) {
146
+ if (err instanceof StaleCopyError) return true;
147
+ return STALE_COPY_RE.test(String(err?.message || ""));
148
+ }
149
+
150
+ /**
151
+ * Whether a canonical entity's `changed` is later than its own
152
+ * `revision_timestamp` — the same fingerprint `drupal_list_revisions`
153
+ * reports as `possiblyPatchBlocked`.
154
+ * @param {?object} entity Canonical entity or revision summary.
155
+ * @returns {boolean}
156
+ */
157
+ export function changedAheadOfRevision(entity) {
158
+ if (!entity || typeof entity !== "object") return false;
159
+ const fields = entity.fields && typeof entity.fields === "object" ? entity.fields : {};
160
+ const changed = entity.changed;
161
+ const rev = entity.revisionTimestamp ?? fields.revision_timestamp;
162
+ if (!changed || !rev) return false;
163
+ const changedMs = Date.parse(changed);
164
+ const revMs = Date.parse(rev);
165
+ if (!Number.isFinite(changedMs) || !Number.isFinite(revMs)) return false;
166
+ return changedMs > revMs;
167
+ }
168
+
169
+ /**
170
+ * Rewrite Sentinel's save-time stale-version refusal into {@link StaleCopyError}.
171
+ * @param {unknown} err
172
+ * @returns {unknown}
173
+ */
174
+ export function rewriteStaleCopyError(err) {
175
+ if (!isStaleCopyError(err) || err instanceof StaleCopyError) return err;
176
+ return new StaleCopyError(err instanceof Error ? err : new Error(String(err)));
177
+ }
178
+
101
179
  /**
102
180
  * Thrown when `rel:working-copy` resolves to a different UUID than the
103
181
  * entity being updated.
@@ -305,6 +383,25 @@ export async function prepareGuardedPatch(backend, {
305
383
  const target = shouldPreflightPatch({ existing, attributes })
306
384
  ? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
307
385
  : { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
386
+ if (shouldPreflightPatch({ existing, attributes }) && !target.resourceVersion) {
387
+ // Canonical path (no distinct working copy). The id-mismatch probe never
388
+ // reaches Sentinel's save-time stale-default check; refuse here when the
389
+ // possiblyPatchBlocked fingerprint is already readable (#273).
390
+ let fingerprint = existing;
391
+ const fields = fingerprint?.fields && typeof fingerprint.fields === "object" ? fingerprint.fields : {};
392
+ const hasTimestamps = Boolean(
393
+ fingerprint?.changed && (fingerprint.revisionTimestamp || fields.revision_timestamp),
394
+ );
395
+ if (!hasTimestamps && typeof backend?.getEntity === "function") {
396
+ const latest = await backend.getEntity({
397
+ entityType, bundle, id, resourceVersion: "rel:latest-version",
398
+ }).catch(() => null);
399
+ if (latest) fingerprint = latest;
400
+ }
401
+ if (changedAheadOfRevision(fingerprint)) {
402
+ throw new StaleCopyError();
403
+ }
404
+ }
308
405
  if (langcode) {
309
406
  if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
310
407
  throw new Error(
@@ -313,23 +410,35 @@ export async function prepareGuardedPatch(backend, {
313
410
  );
314
411
  }
315
412
  target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
316
- await writeDraft(backend, {
317
- entityType, bundle, id, attributes, relationships, langcode,
318
- draftRevision: target.draftRevision,
319
- }, true);
413
+ try {
414
+ await writeDraft(backend, {
415
+ entityType, bundle, id, attributes, relationships, langcode,
416
+ draftRevision: target.draftRevision,
417
+ }, true);
418
+ } catch (err) {
419
+ throw rewriteStaleCopyError(err);
420
+ }
320
421
  return target;
321
422
  }
322
423
  if (target.resourceVersion) {
323
424
  target.draftRevision = { liveVid: target.liveVid, workingVid: target.workingVid };
324
- await writeDraft(backend, {
325
- entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
326
- }, true);
425
+ try {
426
+ await writeDraft(backend, {
427
+ entityType, bundle, id, attributes, relationships, draftRevision: target.draftRevision,
428
+ }, true);
429
+ } catch (err) {
430
+ throw rewriteStaleCopyError(err);
431
+ }
327
432
  return target;
328
433
  }
329
- await preflightPatchWritable({
330
- backend, entityType, bundle, id, existing, attributes,
331
- resourceVersion: target.resourceVersion,
332
- });
434
+ try {
435
+ await preflightPatchWritable({
436
+ backend, entityType, bundle, id, existing, attributes,
437
+ resourceVersion: target.resourceVersion,
438
+ });
439
+ } catch (err) {
440
+ throw rewriteStaleCopyError(err);
441
+ }
333
442
  return target;
334
443
  }
335
444
 
@@ -350,6 +459,8 @@ export async function updateEntityGuarded(backend, input) {
350
459
  }
351
460
  return await backend.updateEntity(input);
352
461
  } catch (err) {
462
+ const stale = rewriteStaleCopyError(err);
463
+ if (stale !== err) throw stale;
353
464
  if (!isWorkingCopyPatchError(err)) throw err;
354
465
  const cause = err instanceof Error ? err : new Error(String(err));
355
466
  if (input?.resourceVersion === "rel:working-copy") {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * URL-alias helpers shared by node writes and the JSON:API path reader.
3
+ *
4
+ * Path aliases are not revisioned. The node's `path` field is a computed
5
+ * view of `path_alias` rows; unpublished / forward revisions often omit
6
+ * `pid` even when a row exists. Round-tripping that pid (and verifying
7
+ * after save) is how title-only edits keep the existing alias (#274).
8
+ */
9
+
10
+ /** JSON:API entity type + bundle for a path alias row. */
11
+ export const PATH_ALIAS_ENTITY_TYPE = "path_alias";
12
+
13
+ /**
14
+ * Normalize a URL-alias path for storage/comparison: trim, ensure a single
15
+ * leading slash, drop a trailing slash (except root).
16
+ * @param {*} value A raw alias.
17
+ * @returns {?string} The normalized alias, or null when empty.
18
+ */
19
+ export function normalizeAlias(value) {
20
+ if (value === undefined || value === null) return null;
21
+ let s = String(value).trim();
22
+ if (!s) return null;
23
+ if (!s.startsWith("/")) s = `/${s}`;
24
+ if (s.length > 1) s = s.replace(/\/+$/, "");
25
+ return s;
26
+ }
27
+
28
+ /**
29
+ * Whether a value is a positive integer node id suitable for `/node/{nid}`.
30
+ * @param {*} value Raw drupal internal id.
31
+ * @returns {boolean}
32
+ */
33
+ export function isPositiveNid(value) {
34
+ if (value === undefined || value === null || value === "") return false;
35
+ const n = Number(value);
36
+ return Number.isInteger(n) && n > 0;
37
+ }
@@ -294,7 +294,7 @@ export const definitions = [
294
294
  langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
295
295
  attributes: { type: "object" },
296
296
  relationships: { type: "object" },
297
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
297
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
298
298
  returning: RETURNING_SCHEMA,
299
299
  },
300
300
  },
@@ -14,7 +14,7 @@ import {
14
14
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
15
15
  } from "../lib/security.js";
16
16
  import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
17
- import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
17
+ import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA, omitLiveComputedMetatag } from "../lib/entity-response.js";
18
18
  import { resolveErrRelationships, relationshipsWereSent } 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";
@@ -22,25 +22,11 @@ import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } f
22
22
  import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
23
23
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
24
24
  import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
25
+ import { normalizeAlias, PATH_ALIAS_ENTITY_TYPE } from "../lib/path-alias.js";
25
26
 
26
27
  /** Fallback language for an alias when the node exposes none. */
27
28
  const DEFAULT_ALIAS_LANGCODE = "en";
28
29
 
29
- /**
30
- * Normalize a URL-alias path for storage/comparison: trim, ensure a single
31
- * leading slash, drop a trailing slash (except root).
32
- * @param {*} value A raw alias.
33
- * @returns {?string} The normalized alias, or null when empty.
34
- */
35
- function normalizeAlias(value) {
36
- if (value === undefined || value === null) return null;
37
- let s = String(value).trim();
38
- if (!s) return null;
39
- if (!s.startsWith("/")) s = `/${s}`;
40
- if (s.length > 1) s = s.replace(/\/+$/, "");
41
- return s;
42
- }
43
-
44
30
  /**
45
31
  * Resolve the `path` attribute to send on an alias-aware node write so the alias
46
32
  * actually persists, and decide whether a rename redirect is needed.
@@ -58,6 +44,8 @@ function normalizeAlias(value) {
58
44
  * run on create); `redirect` is `{ from, to, nid }` when a rename redirect is due.
59
45
  */
60
46
  async function resolvePathWrite({ backend, type, id, providedPath, isCreate }) {
47
+ // Always read the default revision + path_alias row. Aliases are not
48
+ // revisioned; a working-copy GET often omits pid on unpublished drafts (#274).
61
49
  const info = id
62
50
  ? await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}))
63
51
  : {};
@@ -133,6 +121,80 @@ async function createRenameRedirect(backend, sec, redirect) {
133
121
  }
134
122
  }
135
123
 
124
+ /**
125
+ * Update the existing path_alias row in place when a node PATCH did not stick
126
+ * the intended alias (Pathauto regenerating on title save, missing pid on
127
+ * unpublished drafts).
128
+ * @param {object} backend
129
+ * @param {object} sec
130
+ * @param {{aliasId?: ?string}} info
131
+ * @param {string} intendedAlias
132
+ * @returns {Promise<{repaired: boolean, reason?: string}>}
133
+ */
134
+ async function repairAliasViaPathAlias(backend, sec, info, intendedAlias) {
135
+ if (!info?.aliasId) return { repaired: false, reason: "no path_alias id to update" };
136
+ try {
137
+ assertWriteAllowed(sec, "update", PATH_ALIAS_ENTITY_TYPE, PATH_ALIAS_ENTITY_TYPE);
138
+ } catch {
139
+ return { repaired: false, reason: "path_alias update not permitted by policy" };
140
+ }
141
+ try {
142
+ await backend.updateEntity({
143
+ entityType: PATH_ALIAS_ENTITY_TYPE,
144
+ bundle: PATH_ALIAS_ENTITY_TYPE,
145
+ id: info.aliasId,
146
+ attributes: { alias: intendedAlias },
147
+ });
148
+ return { repaired: true };
149
+ } catch (err) {
150
+ return { repaired: false, reason: err?.message || String(err) };
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Confirm the intended alias is what canonical / working-copy reads return.
156
+ * If the node PATCH drifted (Pathauto), restore the path_alias row and
157
+ * re-read. Still-wrong reads throw — never report success for a failed
158
+ * restoration (#274).
159
+ * @param {object} args
160
+ * @returns {Promise<object>} The entity to return (possibly a post-repair re-read).
161
+ */
162
+ async function assertAliasPersisted({
163
+ backend, sec, type, id, intendedAlias, resourceVersion, written,
164
+ }) {
165
+ const shown = normalizeAlias(written?.url);
166
+ if (shown === intendedAlias) return written;
167
+
168
+ const info = await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}));
169
+ if (normalizeAlias(info.alias) === intendedAlias && shown === null) {
170
+ // Re-read omitted url but the alias row matches; treat as persisted.
171
+ return written;
172
+ }
173
+
174
+ const repair = await repairAliasViaPathAlias(backend, sec, info, intendedAlias);
175
+ const fresh = await backend.getEntity({
176
+ entityType: "node", bundle: type, id,
177
+ ...(resourceVersion ? { resourceVersion } : {}),
178
+ }).catch(() => null);
179
+ const afterInfo = await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}));
180
+ const entityUrl = normalizeAlias(fresh?.url);
181
+ const aliasUrl = normalizeAlias(afterInfo.alias);
182
+ // The path_alias row is router-visible; the node's computed path field can
183
+ // lag. Persist if either re-read matches. Never return a drifted url.
184
+ if (entityUrl === intendedAlias || aliasUrl === intendedAlias) {
185
+ const entity = fresh ?? written;
186
+ if (normalizeAlias(entity?.url) === intendedAlias) return entity;
187
+ return { ...(entity && typeof entity === "object" ? entity : { id }), url: intendedAlias };
188
+ }
189
+ const reported = entityUrl || aliasUrl || shown || "(none)";
190
+ throw new Error(
191
+ `Alias restoration failed: requested "${intendedAlias}" but canonical ` +
192
+ `and working-copy reads still show "${reported}"` +
193
+ (repair.reason ? ` (${repair.reason})` : "") +
194
+ ". Update the path_alias entity directly. See connector #274."
195
+ );
196
+ }
197
+
136
198
  /**
137
199
  * Build a Drupal body field descriptor from plain HTML + optional summary.
138
200
  *
@@ -191,7 +253,7 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
191
253
  entityType: "node", bundle: type, id, langcode: targetLang,
192
254
  draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
193
255
  });
194
- return redactCanonicalEntity(entity, sec, "node");
256
+ return omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
195
257
  }
196
258
  const liveHas = (inventory.live?.translations ?? []).some((row) => row.langcode === targetLang);
197
259
  if (!liveHas) {
@@ -218,8 +280,9 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion })
218
280
  *
219
281
  * @param {object} args - { site?, type, status?, filters?, limit?, offset?, sort? }.
220
282
  * A `status` boolean is appended to `filters` as a status equality descriptor.
221
- * @returns {Promise<{total: number, approximate: boolean, offset: number,
222
- * nextOffset: number, nodes: object[]}>} Paged, redacted node list.
283
+ * @returns {Promise<{total: number, approximate: boolean, truncated: boolean,
284
+ * offset: number, nextOffset: number, hasNext: boolean, nodes: object[]}>}
285
+ * Paged, redacted node list. `hasNext` is the backend's `links.next` signal.
223
286
  */
224
287
  async function listNodes({ site: siteName, type, status, filters = [], limit = 20, offset = 0, sort = [{ field: "changed", dir: "desc" }] }) {
225
288
  const site = getSiteConfig(siteName);
@@ -233,8 +296,10 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
233
296
  return {
234
297
  total: res.page?.total ?? nodes.length,
235
298
  approximate: res.approximate ?? false,
299
+ truncated: res.truncated ?? false,
236
300
  offset,
237
301
  nextOffset: offset + nodes.length,
302
+ hasNext: Boolean(res.page?.hasNext),
238
303
  nodes,
239
304
  };
240
305
  }
@@ -399,16 +464,24 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
399
464
  ...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
400
465
  ...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
401
466
  });
402
- const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
403
467
  // #169: when relationships were sent, the canonical re-read is the published
404
468
  // revision and is not proof an ERR field landed. Prefer rel:working-copy.
405
- const fresh = await readWrittenRevision({
469
+ let fresh = await readWrittenRevision({
406
470
  backend, entityType: "node", bundle: type, id,
407
471
  relationshipsSent: relationshipsWereSent(resolvedRelationships),
408
472
  patchResult: patched,
409
473
  preferCanonical: true,
410
474
  resourceVersion: patchTarget.resourceVersion,
411
475
  });
476
+ const intendedAlias = pathAttr?.alias ? normalizeAlias(pathAttr.alias) : null;
477
+ if (intendedAlias) {
478
+ fresh = await assertAliasPersisted({
479
+ backend, sec, type, id, intendedAlias,
480
+ resourceVersion: patchTarget.resourceVersion, written: fresh,
481
+ });
482
+ }
483
+ // Redirect only after the new alias is what re-reads show (#274).
484
+ const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
412
485
  const withRevs = await attachWrittenRevisionPair({
413
486
  backend, entityType: "node", bundle: type, id, entity: fresh, liveVid: patchTarget.liveVid,
414
487
  });
@@ -458,7 +531,7 @@ export const definitions = [
458
531
  },
459
532
  {
460
533
  name: "drupal_list_nodes",
461
- description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors.",
534
+ description: "List nodes of a given content type. Supports status filtering, pagination, sorting, and structured filter descriptors. Drupal core JSON:API caps page[limit] at 50; a larger requested limit is filled by following links.next (up to 1000). When the site does not expose meta.count, total is exact only if this window reached the end of the collection; otherwise approximate is true and hasNext is set.",
462
535
  inputSchema: {
463
536
  type: "object", required: ["type"],
464
537
  properties: {
@@ -524,8 +597,8 @@ export const definitions = [
524
597
  moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'." },
525
598
  langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
526
599
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
527
- relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot." },
528
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. Any refusal fails the dryRun." },
600
+ relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
601
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
529
602
  returning: RETURNING_SCHEMA,
530
603
  },
531
604
  },
@@ -42,6 +42,11 @@ import {
42
42
  import {
43
43
  embedParagraphRef, paragraphRevisionId, resolveParagraphRevisionId, missingParagraphRevisionError,
44
44
  } from "../lib/err-relationships.js";
45
+ import {
46
+ assertDraftLangcode,
47
+ readDraftTranslation,
48
+ writeDraft,
49
+ } from "../lib/draft-write.js";
45
50
 
46
51
  /**
47
52
  * Build the resource-identifier ref used to embed a paragraph in a host ERR /
@@ -111,39 +116,136 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
111
116
  * @throws {Error} If id is missing or the revision id cannot be read.
112
117
  * @throws {SecurityError} If updating paragraphs of this bundle is not permitted.
113
118
  */
114
- async function updateParagraph({ site: siteName, paragraphType, id, attributes = {} }) {
119
+ async function updateParagraph({ site: siteName, paragraphType, id, attributes = {}, langcode, revisionId, draftState }) {
115
120
  if (!id) throw new Error("A paragraph 'id' (UUID) is required to update an existing paragraph.");
116
121
  const site = getSiteConfig(siteName);
117
122
  const sec = resolveSecurityConfig(site);
118
123
  assertWriteAllowed(sec, "update", "paragraph", paragraphType);
119
124
  const backend = await resolveBackend(site);
120
- const paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
125
+ let paragraph;
126
+ if (langcode) {
127
+ const targetLang = assertDraftLangcode(langcode);
128
+ const pinned = revisionId ?? paragraphRevisionId(
129
+ await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id }),
130
+ );
131
+ if (pinned === null || pinned === undefined || pinned === "") {
132
+ throw new Error("Paragraph translation update requires a paragraph revision ID (the host pin).");
133
+ }
134
+ paragraph = await writeDraft(backend, {
135
+ entityType: "paragraph", bundle: paragraphType, id, attributes, langcode: targetLang, draftState,
136
+ draftRevision: { revisionId: pinned },
137
+ });
138
+ } else {
139
+ paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
140
+ }
121
141
  const bundle = paragraph.bundle || paragraphType;
122
- const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
123
- if (revisionId === null) throw missingParagraphRevisionError(id, "Updated");
124
- const ref = embedRef(bundle, paragraph.id, revisionId);
142
+ const resolvedRevisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
143
+ if (resolvedRevisionId === null) throw missingParagraphRevisionError(id, "Updated");
144
+ const ref = embedRef(bundle, paragraph.id, resolvedRevisionId);
125
145
  return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
126
146
  }
127
147
 
148
+ /**
149
+ * JSON:API `resourceVersion` selector for a paragraph host pin.
150
+ * Accepts a numeric vid, a numeric string, or an explicit `id:<vid>`.
151
+ * @param {string|number} revisionId Host pin / paragraph revision id.
152
+ * @returns {string} e.g. `id:6654`.
153
+ * @throws {Error} If the value is not a usable revision id.
154
+ */
155
+ export function paragraphResourceVersion(revisionId) {
156
+ if (typeof revisionId === "number" && Number.isFinite(revisionId) && revisionId > 0) {
157
+ return `id:${revisionId}`;
158
+ }
159
+ if (typeof revisionId === "string" && revisionId.length) {
160
+ if (/^id:[1-9]\d*$/.test(revisionId)) return revisionId;
161
+ if (/^[1-9]\d*$/.test(revisionId)) return `id:${revisionId}`;
162
+ }
163
+ throw new Error(
164
+ `Invalid paragraph revisionId "${revisionId}". Use a numeric vid (the host pin).`,
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Numeric vid from a host pin (`6654` or `id:6654`).
170
+ * @param {string|number} revisionId
171
+ * @returns {?number}
172
+ */
173
+ function requestedParagraphVid(revisionId) {
174
+ const raw = String(revisionId).replace(/^id:/, "");
175
+ const n = Number(raw);
176
+ return Number.isFinite(n) && n > 0 ? n : null;
177
+ }
178
+
179
+ /**
180
+ * Fail closed when a requested paragraph revision was not the one served.
181
+ * Silent substitution of the default revision is the defect this guards (#292).
182
+ * @param {?object} entity Canonical paragraph, or null if the read missed.
183
+ * @param {string|number} requested Host pin that was asked for.
184
+ * @param {string} id Paragraph UUID.
185
+ * @returns {object} `entity` when the served vid matches.
186
+ * @throws {Error} On a miss or a mismatched vid.
187
+ */
188
+ function requireServedParagraphRevision(entity, requested, id) {
189
+ const wanted = requestedParagraphVid(requested);
190
+ const served = paragraphRevisionId(entity);
191
+ if (!entity || wanted === null || served === null || served !== wanted) {
192
+ const got = !entity || served === null ? "no revision" : String(served);
193
+ throw new Error(
194
+ `Requested paragraph revision ${wanted ?? requested} for ${id} but the backend served ${got}. ` +
195
+ "Refusing to substitute a different revision.",
196
+ );
197
+ }
198
+ return entity;
199
+ }
200
+
128
201
  /**
129
202
  * Fetch a single paragraph by bundle + UUID, redacted per the site policy, and
130
203
  * annotate it with the embedding ref (including `meta.target_revision_id`).
131
204
  *
132
- * @param {object} args - { site?, paragraphType, id }.
205
+ * When `revisionId` is set (the host's `meta.target_revision_id`), the read
206
+ * addresses that revision via JSON:API `?resourceVersion=id:<vid>` and errors
207
+ * if Drupal serves a different vid. Omitting `revisionId` still reads the
208
+ * default revision.
209
+ *
210
+ * @param {object} args - { site?, paragraphType, id, langcode?, revisionId? }.
133
211
  * @returns {Promise<(object & {ref: object})|null>}
134
- * The redacted paragraph with an embedding `ref`, or null if not found.
212
+ * The redacted paragraph with an embedding `ref`, or null if not found and
213
+ * no `revisionId` was requested.
135
214
  * @throws {SecurityError} If reading paragraphs of this bundle is not permitted.
215
+ * @throws {Error} If a requested `revisionId` cannot be served.
136
216
  */
137
- async function getParagraph({ site: siteName, paragraphType, id }) {
217
+ async function getParagraph({ site: siteName, paragraphType, id, langcode, revisionId }) {
138
218
  const site = getSiteConfig(siteName);
139
219
  const sec = resolveSecurityConfig(site);
140
220
  assertReadAllowed(sec, "paragraph", paragraphType);
141
221
  const backend = await resolveBackend(site);
142
- const entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
222
+ const pinRequested = revisionId !== undefined && revisionId !== null && revisionId !== "";
223
+ let entity;
224
+ if (langcode) {
225
+ const targetLang = assertDraftLangcode(langcode);
226
+ const pinned = pinRequested ? revisionId : paragraphRevisionId(
227
+ await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id }),
228
+ );
229
+ if (pinned === null || pinned === undefined || pinned === "") {
230
+ throw new Error("Paragraph translation read requires a paragraph revision ID (the host pin).");
231
+ }
232
+ entity = await readDraftTranslation(backend, {
233
+ entityType: "paragraph", bundle: paragraphType, id, langcode: targetLang,
234
+ draftRevision: { revisionId: pinned },
235
+ });
236
+ } else if (pinRequested) {
237
+ const resourceVersion = paragraphResourceVersion(revisionId);
238
+ entity = await backend.getEntity({
239
+ entityType: "paragraph", bundle: paragraphType, id, resourceVersion,
240
+ });
241
+ requireServedParagraphRevision(entity, revisionId, id);
242
+ } else {
243
+ entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
244
+ }
143
245
  if (!entity) return null;
144
246
  const redacted = redactCanonicalEntity(entity, sec, "paragraph");
145
- const revisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted);
146
- return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id, revisionId) };
247
+ const resolvedRevisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted);
248
+ return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id, resolvedRevisionId) };
147
249
  }
148
250
 
149
251
  // ---------------------------------------------------------------------------
@@ -167,7 +269,7 @@ export const definitions = [
167
269
  {
168
270
  name: "drupal_update_paragraph",
169
271
  description:
170
- "Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.",
272
+ "Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Pass langcode to continue an unpublished paragraph translation via Sentinel (the host pin, not a live English mutation). Omitting langcode still uses canonical JSON:API and remains gated on published-host children. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.",
171
273
  inputSchema: {
172
274
  type: "object", required: ["paragraphType", "id"],
173
275
  properties: {
@@ -175,19 +277,24 @@ export const definitions = [
175
277
  paragraphType: { type: "string", description: "Paragraph type / bundle machine name, e.g. 'text', 'image', 'cta'" },
176
278
  id: { type: "string", description: "Paragraph UUID" },
177
279
  attributes: { type: "object", description: "Paragraph field values to change, keyed by Drupal machine name, e.g. { field_body: { value: '<p>..</p>', format: 'full_html' } }" },
280
+ langcode: { type: "string", description: "Target language for an unpublished paragraph translation (e.g. 'es'). Continues Sentinel /mcp-draft; does not create a missing translation." },
281
+ revisionId: { type: "string", description: "Paragraph revision id the host already pins. Required when that pin is not the default revision." },
282
+ draftState: { type: "string", pattern: "^[a-f0-9]{64}$", description: "Required with langcode. Opaque draftState from the previous paragraph draft read/create/update; prevents overwriting newer edits." },
178
283
  },
179
284
  },
180
285
  },
181
286
  {
182
287
  name: "drupal_get_paragraph",
183
288
  description:
184
- "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
289
+ "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Pass revisionId (the host pin) to read that revision via JSON:API resourceVersion=id:<vid>; a mismatch or miss is an error, not a silent default-revision fallback. Pass langcode to read an unpublished working translation via Sentinel. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.",
185
290
  inputSchema: {
186
291
  type: "object", required: ["paragraphType", "id"],
187
292
  properties: {
188
293
  site: { type: "string" },
189
294
  paragraphType: { type: "string", description: "Paragraph type / bundle machine name" },
190
295
  id: { type: "string", description: "Paragraph UUID" },
296
+ langcode: { type: "string", description: "Target language for the unpublished working translation (e.g. 'es')." },
297
+ revisionId: { type: "string", description: "Paragraph revision id the host already pins (ERR meta.target_revision_id). Honored on the read; a different served vid is an error." },
191
298
  },
192
299
  },
193
300
  },
@@ -24,6 +24,7 @@ import {
24
24
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
25
25
  } from "../lib/security.js";
26
26
  import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/draft-write.js";
27
+ import { omitLiveComputedMetatag } from "../lib/entity-response.js";
27
28
 
28
29
  // Attributes that describe the entity's identity / revision bookkeeping / paths.
29
30
  // These are read-only or server-managed and must NOT be replayed on a revert.
@@ -223,7 +224,7 @@ async function getRevision({ site: siteName, type, id, version, langcode }) {
223
224
  entityType: "node", bundle: type, id, langcode: targetLang,
224
225
  draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
225
226
  });
226
- const redacted = redactCanonicalEntity(entity, sec, "node");
227
+ const redacted = omitLiveComputedMetatag(redactCanonicalEntity(entity, sec, "node"));
227
228
  return {
228
229
  entityType: "node",
229
230
  bundle: type,