drupal-mcp-connector 2.6.1 → 2.7.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.
package/src/lib/verify.js CHANGED
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { createHash } from "node:crypto";
21
21
  import { CLIENT_VERSION } from "./config.js";
22
+ import { resolveInboundAuthConfig, resolveInboundAuthMode } from "./http-auth.js";
22
23
 
23
24
  /** Check outcome vocabulary.
24
25
  *
@@ -44,6 +45,7 @@ export const STATIC_CHECKS = [
44
45
  "entitlement",
45
46
  "target_resolution",
46
47
  "tenant_neutrality",
48
+ "inbound_auth",
47
49
  ];
48
50
 
49
51
  /**
@@ -222,13 +224,14 @@ export function configDigest(config) {
222
224
  * credentials, no side effects.
223
225
  *
224
226
  * @param {object} config Parsed connector configuration.
225
- * @param {{source?: string, now?: () => Date}} [options]
227
+ * @param {{source?: string, now?: () => Date, env?: NodeJS.ProcessEnv}} [options]
226
228
  * `source` names what was verified (a path, or a label) for the evidence;
227
- * `now` is injectable so a run is reproducible in tests.
229
+ * `now` is injectable so a run is reproducible in tests;
230
+ * `env` is the process environment under verification (defaults to `process.env`).
228
231
  * @returns {object} Evidence document: tool, version, subject, checks,
229
232
  * residuals and a summary. Never contains secret values.
230
233
  */
231
- export function verifyStatic(config, { source = "config", now = () => new Date() } = {}) {
234
+ export function verifyStatic(config, { source = "config", now = () => new Date(), env = process.env } = {}) {
232
235
  const sites = Object.entries(config?.sites ?? {});
233
236
  const named = (name, message) => `${name}: ${message}`;
234
237
  const nothingToCheck = sites.length === 0;
@@ -364,12 +367,44 @@ export function verifyStatic(config, { source = "config", now = () => new Date()
364
367
  const tenantNeutrality = check(
365
368
  "tenant_neutrality",
366
369
  "The configuration names no real tenant hosts or identifiers",
367
- mentionedHosts(config?.sites ?? {})
370
+ mentionedHosts({ sites: config?.sites ?? {}, auth: config?.auth ?? {} })
368
371
  .filter(({ host }) => !isNeutralHost(host))
369
372
  .map(({ path, host }) => `${path}: "${host}" is not a documentation-reserved host; a shipped example must not name a real deployment.`),
370
373
  { skipped: nothingToCheck },
371
374
  );
372
375
 
376
+ const inbound = resolveInboundAuthConfig(config, env);
377
+ const inboundFindings = [];
378
+ if (inbound.issuer || inbound.audience || inbound.resource) {
379
+ if (!inbound.issuer) inboundFindings.push("auth.issuer is missing.");
380
+ else if (!String(inbound.issuer).startsWith("https://")) {
381
+ inboundFindings.push("auth.issuer is not HTTPS.");
382
+ }
383
+ if (!inbound.audience) inboundFindings.push("auth.audience is missing.");
384
+ if (inbound.resource && !String(inbound.resource).startsWith("https://")) {
385
+ inboundFindings.push("auth.resource is not HTTPS.");
386
+ }
387
+ if (inbound.introspectionUrl && !String(inbound.introspectionUrl).startsWith("https://")) {
388
+ inboundFindings.push("auth.introspectionUrl is not HTTPS.");
389
+ }
390
+ }
391
+ const transportName = env.MCP_TRANSPORT || "stdio";
392
+ if (transportName === "https" || transportName === "http") {
393
+ const decision = resolveInboundAuthMode({
394
+ bindHost: env.MCP_BIND_HOST || "0.0.0.0",
395
+ allowUnauth: env.MCP_ALLOW_UNAUTHENTICATED === "1",
396
+ sharedToken: env.MCP_AUTH_TOKEN || "",
397
+ resourceServer: inbound,
398
+ });
399
+ if (decision.mode === "fatal") inboundFindings.push(decision.reason);
400
+ }
401
+
402
+ const inboundAuth = check(
403
+ "inbound_auth",
404
+ "Network-facing HTTPS authenticates as an OAuth protected resource",
405
+ inboundFindings,
406
+ );
407
+
373
408
  const checks = [
374
409
  transport,
375
410
  principalAuth,
@@ -379,6 +414,7 @@ export function verifyStatic(config, { source = "config", now = () => new Date()
379
414
  entitlement,
380
415
  targetResolution,
381
416
  tenantNeutrality,
417
+ inboundAuth,
382
418
  ];
383
419
 
384
420
  const counts = checks.reduce(
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Choose which entity body represents a write (#169).
3
+ *
4
+ * When relationships were sent, the canonical/default revision is the
5
+ * published node — after a draft ERR attach it still shows the *old* refs.
6
+ * Prefer `rel:working-copy`. If that alias is not addressable, return the
7
+ * PATCH body (or the canonical re-read) plus `_revision.relationshipsUnverified`.
8
+ */
9
+
10
+ /**
11
+ * @param {object} args
12
+ * @param {object} args.backend
13
+ * @param {string} args.entityType
14
+ * @param {string} args.bundle
15
+ * @param {string} args.id
16
+ * @param {boolean} args.relationshipsSent
17
+ * @param {?object} [args.patchResult] Canonicalised PATCH response body.
18
+ * @param {boolean} [args.preferCanonical] When no relationships were sent,
19
+ * re-GET the canonical resource (nodes do this for the persisted alias).
20
+ * @returns {Promise<object>} Entity to return, with `_revision` when relevant.
21
+ */
22
+ export async function readWrittenRevision({
23
+ backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
24
+ }) {
25
+ if (!relationshipsSent) {
26
+ if (preferCanonical && typeof backend.getEntity === "function") {
27
+ const fresh = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
28
+ return fresh ?? patchResult ?? { id };
29
+ }
30
+ return patchResult ?? { id };
31
+ }
32
+
33
+ // Content-moderation working-copy aliases are a node (host) feature. Other
34
+ // entity types keep the PATCH body and an unverified marker.
35
+ let workingCopy = null;
36
+ if (entityType === "node" && typeof backend.getEntity === "function") {
37
+ try {
38
+ workingCopy = await backend.getEntity({
39
+ entityType, bundle, id, resourceVersion: "rel:working-copy",
40
+ });
41
+ } catch {
42
+ workingCopy = null;
43
+ }
44
+ }
45
+ if (workingCopy) {
46
+ return {
47
+ ...workingCopy,
48
+ _revision: {
49
+ source: "working-copy",
50
+ note:
51
+ "Returned from rel:working-copy — the revision that was written, not the " +
52
+ "published default. Canonical re-reads hide a draft ERR attach (#169).",
53
+ },
54
+ };
55
+ }
56
+
57
+ let fallback = patchResult;
58
+ if (!fallback && preferCanonical && typeof backend.getEntity === "function") {
59
+ fallback = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
60
+ }
61
+ return {
62
+ ...(fallback ?? { id }),
63
+ _revision: {
64
+ source: patchResult ? "patch" : "canonical",
65
+ relationshipsUnverified: true,
66
+ note:
67
+ "Relationships were sent on this write. The body below is not the written " +
68
+ "revision (no addressable working copy). It is not proof an ERR field landed. " +
69
+ "Inspect rel:working-copy with drupal_get_revision, or treat the field as unverified (#169).",
70
+ },
71
+ };
72
+ }
package/src/tools/bulk.js CHANGED
@@ -14,7 +14,12 @@
14
14
  import { getSiteConfig } from "../lib/config.js";
15
15
  import { resolveBackend } from "../lib/backends/index.js";
16
16
  import { resolveSecurityConfig, assertWriteAllowed, assertPublishAllowed } from "../lib/security.js";
17
- import { applySafeDraftDefault } from "../lib/moderation-default.js";
17
+ import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
18
+ import {
19
+ resolveErrRelationships, embedParagraphRef,
20
+ resolveParagraphRevisionId, missingParagraphRevisionError,
21
+ } from "../lib/err-relationships.js";
22
+ import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
18
23
 
19
24
  /**
20
25
  * Normalize an unknown thrown value into a human-readable message.
@@ -48,13 +53,20 @@ async function bulkCreate({ site: siteName, entityType, bundle, items = [] }) {
48
53
  const item = rawItem || {};
49
54
  try {
50
55
  assertPublishAllowed(sec, item.attributes ?? {});
56
+ const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
51
57
  const entity = await backend.createEntity({
52
58
  entityType, bundle,
53
59
  attributes: item.attributes ?? {},
54
- relationships: item.relationships ?? {},
60
+ relationships: resolvedRelationships,
55
61
  });
56
62
  created += 1;
57
- results.push({ index, success: true, id: entity?.id });
63
+ const row = { index, success: true, id: entity?.id };
64
+ if (entityType === "paragraph" && entity?.id) {
65
+ const revisionId = await resolveParagraphRevisionId(backend, entity, bundle);
66
+ if (revisionId === null) throw missingParagraphRevisionError(entity.id);
67
+ row.relationshipData = embedParagraphRef(entity.bundle || bundle, entity.id, revisionId);
68
+ }
69
+ results.push(row);
58
70
  } catch (err) {
59
71
  failed += 1;
60
72
  results.push({ index, success: false, error: errorMessage(err) });
@@ -91,15 +103,28 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
91
103
  const item = rawItem || {};
92
104
  try {
93
105
  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
+ }
94
114
  const attributes = await applySafeDraftDefault({
95
115
  backend, entityType, bundle, id: item.id,
96
116
  attributes: item.attributes ?? {},
117
+ existingEntity: existing,
97
118
  });
98
119
  assertPublishAllowed(sec, attributes);
99
- const entity = await backend.updateEntity({
120
+ const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
121
+ await preflightPatchWritable({
122
+ backend, entityType, bundle, id: item.id, existing, attributes,
123
+ });
124
+ const entity = await updateEntityGuarded(backend, {
100
125
  entityType, bundle, id: item.id,
101
126
  attributes,
102
- relationships: item.relationships ?? {},
127
+ relationships: resolvedRelationships,
103
128
  });
104
129
  updated += 1;
105
130
  results.push({ index, success: true, id: entity?.id ?? item.id });
@@ -123,7 +148,7 @@ const itemAttributesSchema = {
123
148
  export const definitions = [
124
149
  {
125
150
  name: "drupal_bulk_create",
126
- description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Writes default to unpublished/draft.",
151
+ description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Paragraph items also return relationshipData with meta.target_revision_id for a later host attach. Writes default to unpublished/draft.",
127
152
  inputSchema: {
128
153
  type: "object", required: ["entityType", "bundle", "items"],
129
154
  properties: {
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { getSiteConfig } from "../lib/config.js";
15
+ import { describeTarget, getRequestIdentity } from "../lib/principal.js";
15
16
  import {
16
17
  resolveSecurityConfig,
17
18
  getSecuritySummary,
@@ -117,8 +118,13 @@ async function whoami({ site: siteName }) {
117
118
  // When no OAuth scopes are configured, hasScope() is a no-op (preset-only).
118
119
  const canWrite = !sec.readOnly && hasScope(site, "mcp_write");
119
120
  const canConfig = hasScope(site, "mcp_config");
121
+ const identity = getRequestIdentity();
120
122
  return {
121
123
  site: site._name,
124
+ target: describeTarget(site, siteName ? "hint" : "default"),
125
+ principal: identity
126
+ ? { sub: identity.sub, clientId: identity.clientId, scopes: [...(identity.scopes ?? [])] }
127
+ : null,
122
128
  tier: inferTier(site, sec),
123
129
  preset: summary.preset,
124
130
  scopes: site.oauth?.scopes ?? [],
@@ -12,6 +12,12 @@ 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
14
  import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
15
+ import {
16
+ resolveErrRelationships, relationshipsWereSent, embedParagraphRef,
17
+ resolveParagraphRevisionId, missingParagraphRevisionError,
18
+ } from "../lib/err-relationships.js";
19
+ import { readWrittenRevision } from "../lib/write-revision.js";
20
+ import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
15
21
  import {
16
22
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
17
23
  redactCanonicalEntity, getSecuritySummary,
@@ -63,9 +69,16 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
63
69
  const sec = resolveSecurityConfig(site);
64
70
  assertWriteAllowed(sec, "create", entityType, bundle);
65
71
  assertPublishAllowed(sec, attributes);
66
- if (dryRun) return { dryRun: true, operation: "create", entityType, bundle, attributes, relationships };
67
72
  const backend = await resolveBackend(site);
68
- return shapeWriteResponse(await backend.createEntity({ entityType, bundle, attributes, relationships }), returning);
73
+ const resolvedRelationships = await resolveErrRelationships(backend, relationships);
74
+ if (dryRun) return { dryRun: true, operation: "create", entityType, bundle, attributes, relationships: resolvedRelationships };
75
+ const created = await backend.createEntity({ entityType, bundle, attributes, relationships: resolvedRelationships });
76
+ if (entityType === "paragraph") {
77
+ const revisionId = await resolveParagraphRevisionId(backend, created, bundle);
78
+ if (revisionId === null) throw missingParagraphRevisionError(created.id);
79
+ created.relationshipData = embedParagraphRef(created.bundle || bundle, created.id, revisionId);
80
+ }
81
+ return shapeWriteResponse(created, returning);
69
82
  }
70
83
 
71
84
  /**
@@ -103,9 +116,26 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
103
116
  backend, entityType, bundle, id, attributes, existingEntity: existing,
104
117
  });
105
118
  assertPublishAllowed(sec, safeAttributes);
106
- if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes: safeAttributes, relationships };
107
- const result = await backend.updateEntity({ entityType, bundle, id, attributes: safeAttributes, relationships });
108
- return shapeWriteResponse(flagUnrequestedStatusChange(result, existing, safeAttributes), returning);
119
+ const resolvedRelationships = await resolveErrRelationships(backend, relationships);
120
+ await preflightPatchWritable({
121
+ backend, entityType, bundle, id, existing, attributes: safeAttributes,
122
+ });
123
+ if (dryRun) {
124
+ return {
125
+ dryRun: true, operation: "update", entityType, bundle, id,
126
+ attributes: safeAttributes, relationships: resolvedRelationships,
127
+ };
128
+ }
129
+ const result = await updateEntityGuarded(backend, {
130
+ entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
131
+ });
132
+ const written = await readWrittenRevision({
133
+ backend, entityType, bundle, id,
134
+ relationshipsSent: relationshipsWereSent(resolvedRelationships),
135
+ patchResult: result,
136
+ preferCanonical: false,
137
+ });
138
+ return shapeWriteResponse(flagUnrequestedStatusChange(written, existing, safeAttributes), returning);
109
139
  }
110
140
 
111
141
  /**
@@ -246,7 +276,7 @@ export const definitions = [
246
276
  },
247
277
  {
248
278
  name: "drupal_entity_update",
249
- 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).",
279
+ 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 an id-mismatch PATCH preflight runs first (including dryRun) so a core working-copy guard failure is reported before the real write and no revision is saved by the probe (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.",
250
280
  inputSchema: {
251
281
  type: "object", required: ["entityType", "bundle", "id"],
252
282
  properties: {
@@ -256,7 +286,7 @@ export const definitions = [
256
286
  id: { type: "string" },
257
287
  attributes: { type: "object" },
258
288
  relationships: { type: "object" },
259
- dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
289
+ 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. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
260
290
  returning: RETURNING_SCHEMA,
261
291
  },
262
292
  },
@@ -15,6 +15,9 @@ import {
15
15
  } from "../lib/security.js";
16
16
  import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
17
17
  import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
18
+ import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
19
+ import { readWrittenRevision } from "../lib/write-revision.js";
20
+ import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
18
21
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
19
22
 
20
23
  /** Fallback language for an alias when the node exposes none. */
@@ -269,14 +272,15 @@ async function createNode({ site: siteName, type, title, body, summary, format,
269
272
  const bodyAttr = buildBodyAttribute(body, summary, format, site);
270
273
  if (bodyAttr) attributes.body = bodyAttr;
271
274
  assertPublishAllowed(sec, attributes);
272
- if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
273
275
  const backend = await resolveBackend(site);
276
+ const resolvedRelationships = await resolveErrRelationships(backend, relationships);
277
+ if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships: resolvedRelationships };
274
278
  // Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
275
279
  // `path` is omitted so pathauto generates the alias (DEV-116).
276
280
  const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
277
281
  if (pathAttr === undefined) delete attributes.path;
278
282
  else attributes.path = pathAttr;
279
- const created = await backend.createEntity({ entityType: "node", bundle: type, attributes, relationships });
283
+ const created = await backend.createEntity({ entityType: "node", bundle: type, attributes, relationships: resolvedRelationships });
280
284
  // Honest response: re-read so the persisted alias (explicit or pathauto-generated)
281
285
  // is reflected rather than the pre-alias write response.
282
286
  const fresh = await backend.getEntity({ entityType: "node", bundle: type, id: created.id }).catch(() => null);
@@ -334,7 +338,20 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
334
338
  backend, entityType: "node", bundle: type, id, attributes, existingEntity: existing,
335
339
  });
336
340
  assertPublishAllowed(sec, attributes);
337
- if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes, relationships };
341
+ // #192: resolve paragraph ERR identifiers before any host PATCH. An unresolved
342
+ // list would persist empty — fail the whole write instead.
343
+ const resolvedRelationships = await resolveErrRelationships(backend, relationships);
344
+ // #201: the core working-copy guard runs before deserialize. A no-op probe
345
+ // against the same canonical URL is a true preflight, including on dryRun.
346
+ await preflightPatchWritable({
347
+ backend, entityType: "node", bundle: type, id, existing, attributes,
348
+ });
349
+ if (dryRun) {
350
+ return {
351
+ dryRun: true, operation: "update", entityType: "node", bundle: type, id,
352
+ attributes, relationships: resolvedRelationships,
353
+ };
354
+ }
338
355
  // Alias handling (DEV-116): an explicit `path.alias` is set in place by
339
356
  // round-tripping the existing alias's pid (no duplicate); a path-less update
340
357
  // re-pins the current alias *with its pid* so the save can't revert/duplicate
@@ -342,11 +359,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
342
359
  const { pathAttr, redirect } = await resolvePathWrite({ backend, type, id, providedPath: attributes.path, isCreate: false });
343
360
  if (pathAttr === undefined) delete attributes.path;
344
361
  else attributes.path = pathAttr;
345
- await backend.updateEntity({ entityType: "node", bundle: type, id, attributes, relationships });
362
+ const patched = await updateEntityGuarded(backend, {
363
+ entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
364
+ });
346
365
  const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
347
- // Honest response: re-read persisted state so the returned `url` is the alias
348
- // that actually resolves, never the just-sent value.
349
- const fresh = await backend.getEntity({ entityType: "node", bundle: type, id }).catch(() => null);
366
+ // #169: when relationships were sent, the canonical re-read is the published
367
+ // revision and is not proof an ERR field landed. Prefer rel:working-copy.
368
+ const fresh = await readWrittenRevision({
369
+ backend, entityType: "node", bundle: type, id,
370
+ relationshipsSent: relationshipsWereSent(resolvedRelationships),
371
+ patchResult: patched,
372
+ preferCanonical: true,
373
+ });
350
374
  // #171: an unrequested published-state flip in the persisted node is
351
375
  // reported via _statusChanged rather than returned as a clean success.
352
376
  const flagged = flagUnrequestedStatusChange(fresh, existing, attributes);
@@ -441,7 +465,7 @@ export const definitions = [
441
465
  },
442
466
  {
443
467
  name: "drupal_update_node",
444
- 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. Entity-reference fields go in `relationships`, not `fields`.",
468
+ 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. 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 an id-mismatch PATCH preflight runs first — including on dryRun — so a core working-copy guard failure is reported before the real write and no revision is saved by the probe. 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.",
445
469
  inputSchema: {
446
470
  type: "object", required: ["type", "id"],
447
471
  properties: {
@@ -455,8 +479,8 @@ export const definitions = [
455
479
  status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
456
480
  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'." },
457
481
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
458
- 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 }, …] }." },
459
- dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
482
+ 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." },
483
+ 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. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
460
484
  returning: RETURNING_SCHEMA,
461
485
  },
462
486
  },
@@ -9,17 +9,24 @@
9
9
  * focused way to mint a paragraph and fetch it back, plus the relationship data
10
10
  * needed to embed it into a host field.
11
11
  *
12
- * Embedding model — IMPORTANT:
13
- * - Over JSON:API (this connector's default backend) a host references a
14
- * paragraph from its ERR field by a resource identifier object
15
- * `{ type: "paragraph--<bundle>", id: "<paragraph-uuid>" }`. Drupal resolves
16
- * the correct target_id + target_revision_id server-side from the UUID. Drop
17
- * `relationshipData` (returned by drupal_create_paragraph) into the host's
18
- * relationships map and call drupal_entity_update / drupal_update_node.
12
+ * Embedding model — IMPORTANT (#192):
13
+ * - Over JSON:API a host references a paragraph from its ERR field by a
14
+ * resource identifier `{ type: "paragraph--<bundle>", id, meta: {
15
+ * target_revision_id } }`. Drupal's ERR item is empty unless both
16
+ * `target_id` and `target_revision_id` are set; JSON:API only receives the
17
+ * revision id from `meta.target_revision_id`. `{ type, id }` alone persists
18
+ * an empty field — it is not a no-op, and it is worse than omitting the
19
+ * field (which would inherit the previous revision's refs).
20
+ * - `relationshipData` from these tools includes that meta key. Host writes
21
+ * (`drupal_entity_update` / `drupal_update_node` / bulk update) also resolve
22
+ * a missing vid before PATCH and fail the whole write if they cannot.
19
23
  * - The classic entity-API pair `{ target_id, target_revision_id }` (integer
20
- * ids) is the REST/Form-API shape, not the JSON:API shape. Those numeric ids
21
- * are not surfaced by the canonical entity here; prefer the UUID relationship
22
- * form above when writing through this connector.
24
+ * ids) is the REST/Form-API shape, not the JSON:API shape.
25
+ * - Creating paragraphs and then attaching them is two calls. A content-tier
26
+ * agent cannot delete orphans if the host PATCH is then rejected (#201).
27
+ * Probe the host first: `drupal_list_revisions` (`possiblyPatchBlocked`)
28
+ * and `dryRun` on the host update. Preflight inside `update_node` does not
29
+ * un-orphan work that already happened.
23
30
  *
24
31
  * Both tools are governed: writes assert create permission for the `paragraph`
25
32
  * entity type + bundle, reads assert read permission and are redacted per the
@@ -32,33 +39,35 @@ import { resolveBackend } from "../lib/backends/index.js";
32
39
  import {
33
40
  resolveSecurityConfig, assertWriteAllowed, assertReadAllowed, redactCanonicalEntity,
34
41
  } from "../lib/security.js";
35
-
36
- /**
37
- * Build the JSON:API resource type string for a paragraph bundle.
38
- * @param {string} bundle Paragraph type machine name.
39
- * @returns {string} e.g. "paragraph--text".
40
- */
41
- function resourceType(bundle) {
42
- return `paragraph--${bundle}`;
43
- }
42
+ import {
43
+ embedParagraphRef, paragraphRevisionId, resolveParagraphRevisionId, missingParagraphRevisionError,
44
+ } from "../lib/err-relationships.js";
44
45
 
45
46
  /**
46
47
  * Build the resource-identifier ref used to embed a paragraph in a host ERR /
47
- * paragraph reference field over JSON:API.
48
+ * paragraph reference field over JSON:API. Includes `meta.target_revision_id`
49
+ * when a vid is known (#192).
48
50
  * @param {string} bundle Paragraph type machine name.
49
51
  * @param {string} id Paragraph UUID.
50
- * @returns {{type: string, id: string}}
52
+ * @param {number|string|null|undefined} [revisionId] Current revision id.
53
+ * @returns {{type: string, id: string, meta?: {target_revision_id: number}}}
51
54
  */
52
- function embedRef(bundle, id) {
53
- return { type: resourceType(bundle), id };
55
+ export function embedRef(bundle, id, revisionId) {
56
+ return embedParagraphRef(bundle, id, revisionId);
54
57
  }
55
58
 
56
59
  const EMBED_NOTE =
57
60
  "Paragraphs are not standalone content: embed this paragraph in a host entity's " +
58
- "Entity Reference Revisions (paragraph) field. Over JSON:API, add `relationshipData` " +
59
- "to the host field's relationship (e.g. drupal_entity_update / drupal_update_node with " +
60
- "relationships: { field_paragraphs: { data: [ relationshipData ] } }). Drupal resolves " +
61
- "target_id + target_revision_id from the UUID server-side.";
61
+ "Entity Reference Revisions (paragraph) field. Over JSON:API the resource identifier " +
62
+ "MUST include meta.target_revision_id — Drupal's ERR item is empty unless both " +
63
+ "target_id and target_revision_id are set, and JSON:API only receives the revision " +
64
+ "id from that meta key. relationshipData from this tool includes it. " +
65
+ "drupal_entity_update / drupal_update_node also resolve a missing vid before PATCH " +
66
+ "and fail the write if they cannot. An empty array is an explicit clear. " +
67
+ "Before creating paragraphs to attach to a published moderated node, call " +
68
+ "drupal_list_revisions (inspect possiblyPatchBlocked) and dryRun the host update — " +
69
+ "the host write is rejected after dependents exist, and content-tier cannot delete " +
70
+ "the orphans (#201).";
62
71
 
63
72
  /**
64
73
  * Create a paragraph entity of the given type and return a ref suitable for
@@ -68,10 +77,11 @@ const EMBED_NOTE =
68
77
  * `attributes` are paragraph field values keyed by Drupal machine name
69
78
  * (e.g. { field_body: { value, format } }). Use drupal_get_entity_schema for
70
79
  * entityType "paragraph" + the bundle to discover available fields.
71
- * @returns {Promise<{paragraph: object, ref: {id: string, type: string},
72
- * relationshipData: {type: string, id: string}, note: string}>}
73
- * The created paragraph descriptor plus the embedding ref/relationship data.
80
+ * @returns {Promise<{paragraph: object, ref: object, relationshipData: object, note: string}>}
81
+ * The created paragraph descriptor plus the embedding ref/relationship data
82
+ * (including `meta.target_revision_id`).
74
83
  * @throws {SecurityError} If creating paragraphs of this bundle is not permitted.
84
+ * @throws {Error} If the created paragraph has no readable revision id.
75
85
  */
76
86
  async function createParagraph({ site: siteName, paragraphType, attributes = {} }) {
77
87
  const site = getSiteConfig(siteName);
@@ -80,7 +90,9 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
80
90
  const backend = await resolveBackend(site);
81
91
  const paragraph = await backend.createEntity({ entityType: "paragraph", bundle: paragraphType, attributes });
82
92
  const bundle = paragraph.bundle || paragraphType;
83
- const ref = embedRef(bundle, paragraph.id);
93
+ const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
94
+ if (revisionId === null) throw missingParagraphRevisionError(paragraph.id);
95
+ const ref = embedRef(bundle, paragraph.id, revisionId);
84
96
  return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
85
97
  }
86
98
 
@@ -94,10 +106,9 @@ async function createParagraph({ site: siteName, paragraphType, attributes = {}
94
106
  * @param {object} args - { site?, paragraphType, id, attributes? }.
95
107
  * `attributes` are the paragraph field values to change, keyed by Drupal
96
108
  * machine name (e.g. { field_body: { value, format } }).
97
- * @returns {Promise<{paragraph: object, ref: {id: string, type: string},
98
- * relationshipData: {type: string, id: string}, note: string}>}
99
- * The updated paragraph plus the (unchanged) embedding ref.
100
- * @throws {Error} If id is missing.
109
+ * @returns {Promise<{paragraph: object, ref: object, relationshipData: object, note: string}>}
110
+ * The updated paragraph plus the embedding ref (with current revision id).
111
+ * @throws {Error} If id is missing or the revision id cannot be read.
101
112
  * @throws {SecurityError} If updating paragraphs of this bundle is not permitted.
102
113
  */
103
114
  async function updateParagraph({ site: siteName, paragraphType, id, attributes = {} }) {
@@ -108,16 +119,18 @@ async function updateParagraph({ site: siteName, paragraphType, id, attributes =
108
119
  const backend = await resolveBackend(site);
109
120
  const paragraph = await backend.updateEntity({ entityType: "paragraph", bundle: paragraphType, id, attributes });
110
121
  const bundle = paragraph.bundle || paragraphType;
111
- const ref = embedRef(bundle, paragraph.id);
122
+ const revisionId = await resolveParagraphRevisionId(backend, paragraph, paragraphType);
123
+ if (revisionId === null) throw missingParagraphRevisionError(id, "Updated");
124
+ const ref = embedRef(bundle, paragraph.id, revisionId);
112
125
  return { paragraph, ref, relationshipData: ref, note: EMBED_NOTE };
113
126
  }
114
127
 
115
128
  /**
116
129
  * Fetch a single paragraph by bundle + UUID, redacted per the site policy, and
117
- * annotate it with the embedding ref.
130
+ * annotate it with the embedding ref (including `meta.target_revision_id`).
118
131
  *
119
132
  * @param {object} args - { site?, paragraphType, id }.
120
- * @returns {Promise<(object & {ref: {id: string, type: string}})|null>}
133
+ * @returns {Promise<(object & {ref: object})|null>}
121
134
  * The redacted paragraph with an embedding `ref`, or null if not found.
122
135
  * @throws {SecurityError} If reading paragraphs of this bundle is not permitted.
123
136
  */
@@ -129,7 +142,8 @@ async function getParagraph({ site: siteName, paragraphType, id }) {
129
142
  const entity = await backend.getEntity({ entityType: "paragraph", bundle: paragraphType, id });
130
143
  if (!entity) return null;
131
144
  const redacted = redactCanonicalEntity(entity, sec, "paragraph");
132
- return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id) };
145
+ const revisionId = paragraphRevisionId(entity) ?? paragraphRevisionId(redacted);
146
+ return { ...redacted, ref: embedRef(redacted.bundle || paragraphType, redacted.id, revisionId) };
133
147
  }
134
148
 
135
149
  // ---------------------------------------------------------------------------
@@ -140,7 +154,7 @@ export const definitions = [
140
154
  {
141
155
  name: "drupal_create_paragraph",
142
156
  description:
143
- "Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id: <uuid> }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.",
157
+ "Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Drupal ERR items are empty without that meta key — do not send {type, id} alone. Before creating paragraphs to attach to a published moderated node, call drupal_list_revisions (possiblyPatchBlocked) and dryRun the host update so a doomed PATCH does not orphan them. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.",
144
158
  inputSchema: {
145
159
  type: "object", required: ["paragraphType"],
146
160
  properties: {
@@ -153,7 +167,7 @@ export const definitions = [
153
167
  {
154
168
  name: "drupal_update_paragraph",
155
169
  description:
156
- "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 this paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.",
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.",
157
171
  inputSchema: {
158
172
  type: "object", required: ["paragraphType", "id"],
159
173
  properties: {
@@ -167,7 +181,7 @@ export const definitions = [
167
181
  {
168
182
  name: "drupal_get_paragraph",
169
183
  description:
170
- "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph plus a `ref` ({ type: 'paragraph--<bundle>', id }) you can use to embed it in a host entity's paragraph / ERR field. Note: paragraphs are referenced (by target_id + target_revision_id in the entity API, or by UUID over JSON:API) from a host field rather than queried standalone in production. Governed by the site security policy.",
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.",
171
185
  inputSchema: {
172
186
  type: "object", required: ["paragraphType", "id"],
173
187
  properties: {