drupal-mcp-connector 2.7.0 → 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.
@@ -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: {
@@ -112,6 +112,7 @@ function summarizeRevision(resource) {
112
112
  return {
113
113
  vid: attrs.get("drupal_internal__vid") ?? null,
114
114
  revisionTimestamp: attrs.get("revision_timestamp") ?? null,
115
+ changed: attrs.get("changed") ?? null,
115
116
  revisionLog: attrs.get("revision_log") ?? attrs.get("revision_log_message") ?? null,
116
117
  status: attrs.get("status") ?? null,
117
118
  title: attrs.get("title") ?? null,
@@ -119,6 +120,39 @@ function summarizeRevision(resource) {
119
120
  };
120
121
  }
121
122
 
123
+ /**
124
+ * Whether the default revision's `changed` is later than its own
125
+ * `revision_timestamp` — the readable fingerprint of a #201 node.
126
+ * @param {?object} summary
127
+ * @returns {boolean}
128
+ */
129
+ function changedAheadOfRevision(summary) {
130
+ if (!summary?.changed || !summary?.revisionTimestamp) return false;
131
+ const changed = Date.parse(summary.changed);
132
+ const rev = Date.parse(summary.revisionTimestamp);
133
+ if (!Number.isFinite(changed) || !Number.isFinite(rev)) return false;
134
+ return changed > rev;
135
+ }
136
+
137
+ const LIST_REVISIONS_BASE_NOTE =
138
+ "JSON:API only addresses revisions by id (id:<vid>) or the rel:latest-version / " +
139
+ "rel:working-copy aliases; it cannot enumerate the full chronological history. " +
140
+ "Use drupal_get_revision with a specific vid to inspect a known revision, or the " +
141
+ "Drush bridge for complete revision-history enumeration. " +
142
+ "drupal_report_revision_hotspots can surface per-node revision counts.";
143
+
144
+ const LIST_REVISIONS_NULL_WC_NOTE =
145
+ " workingCopy: null is not proof the node is PATCH-able. Drupal core rejects " +
146
+ "PATCH when the stored entity is not the latest revision even when " +
147
+ "content_moderation exposes no pending revision (connector #201, core #2795279). " +
148
+ "Inspect possiblyPatchBlocked before creating dependent paragraphs; dryRun on " +
149
+ "the host update runs the same guard.";
150
+
151
+ const LIST_REVISIONS_FINGERPRINT_NOTE =
152
+ " The default revision's changed timestamp is later than its own " +
153
+ "revision_timestamp — a readable fingerprint that a newer revision row may " +
154
+ "exist without a content_moderation working copy.";
155
+
122
156
  /**
123
157
  * List the addressable revisions of an entity: the latest default revision and
124
158
  * the working-copy (forward) revision. Full chronological enumeration is not
@@ -145,19 +179,20 @@ async function listRevisions({ site: siteName, type, id }) {
145
179
  .then(summarizeRevision)
146
180
  .catch(() => null);
147
181
 
182
+ const possiblyPatchBlocked = Boolean(latestVersion && changedAheadOfRevision(latestVersion));
183
+ let note = LIST_REVISIONS_BASE_NOTE;
184
+ if (!workingCopy) note += LIST_REVISIONS_NULL_WC_NOTE;
185
+ if (possiblyPatchBlocked) note += LIST_REVISIONS_FINGERPRINT_NOTE;
186
+
148
187
  return {
149
188
  entityType: "node",
150
189
  bundle: type,
151
190
  id,
152
191
  latestVersion,
153
192
  workingCopy,
193
+ possiblyPatchBlocked,
154
194
  fullHistoryAvailable: false,
155
- note:
156
- "JSON:API only addresses revisions by id (id:<vid>) or the rel:latest-version / " +
157
- "rel:working-copy aliases; it cannot enumerate the full chronological history. " +
158
- "Use drupal_get_revision with a specific vid to inspect a known revision, or the " +
159
- "Drush bridge for complete revision-history enumeration. " +
160
- "drupal_report_revision_hotspots can surface per-node revision counts.",
195
+ note,
161
196
  };
162
197
  }
163
198
 
@@ -266,7 +301,7 @@ export const definitions = [
266
301
  {
267
302
  name: "drupal_list_revisions",
268
303
  description:
269
- "Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. NOTE: JSON:API cannot enumerate full chronological revision history it only addresses revisions by id or the latest/working-copy aliases. Full history enumeration requires the Drush bridge. Use drupal_report_revision_hotspots for per-node revision counts.",
304
+ "Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. workingCopy: null is not an all-clear Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.",
270
305
  inputSchema: {
271
306
  type: "object", required: ["type", "id"],
272
307
  properties: {