drupal-mcp-connector 2.4.1 → 2.6.0

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.
@@ -10,8 +10,8 @@
10
10
 
11
11
  import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
- import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
14
- import { applySafeDraftDefault } from "../lib/moderation-default.js";
13
+ import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
14
+ import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
15
15
  import {
16
16
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
17
17
  redactCanonicalEntity, getSecuritySummary,
@@ -75,6 +75,10 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
75
75
  * `moderation_state` get `moderation_state: draft` so the write is a forward
76
76
  * revision rather than a live default-revision mutation.
77
77
  *
78
+ * Live-state mediation (#171): `status` is never added to the PATCH unless the
79
+ * caller passed it, and an unrequested published-state flip in the write result
80
+ * is reported via `_statusChanged` rather than returned silently.
81
+ *
78
82
  * @param {object} args - { site?, entityType, bundle, id, attributes?, relationships? }.
79
83
  * @returns {Promise<object>} The updated entity descriptor.
80
84
  * @throws {SecurityError} If updating the type/bundle is not permitted.
@@ -84,12 +88,24 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
84
88
  const sec = resolveSecurityConfig(site);
85
89
  assertWriteAllowed(sec, "update", entityType, bundle);
86
90
  const backend = await resolveBackend(site);
91
+ // One pre-write read serves both the #131 draft default and the #171
92
+ // unrequested-status-change flag. Skipped when the caller pinned the
93
+ // moderation state explicitly (same condition the draft default uses).
94
+ let existing = null;
95
+ if (!hasExplicitModerationState(attributes)) {
96
+ try {
97
+ existing = (await backend.getEntity({ entityType, bundle, id })) ?? null;
98
+ } catch {
99
+ existing = null; // Unreadable target: server-side gates stay authoritative.
100
+ }
101
+ }
87
102
  const safeAttributes = await applySafeDraftDefault({
88
- backend, entityType, bundle, id, attributes,
103
+ backend, entityType, bundle, id, attributes, existingEntity: existing,
89
104
  });
90
105
  assertPublishAllowed(sec, safeAttributes);
91
106
  if (dryRun) return { dryRun: true, operation: "update", entityType, bundle, id, attributes: safeAttributes, relationships };
92
- return shapeWriteResponse(await backend.updateEntity({ entityType, bundle, id, attributes: safeAttributes, relationships }), returning);
107
+ const result = await backend.updateEntity({ entityType, bundle, id, attributes: safeAttributes, relationships });
108
+ return shapeWriteResponse(flagUnrequestedStatusChange(result, existing, safeAttributes), returning);
93
109
  }
94
110
 
95
111
  /**
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { getSiteConfig } from "../lib/config.js";
10
10
  import { resolveBackend } from "../lib/backends/index.js";
11
+ import { splitReferenceFields } from "../lib/canonical.js";
12
+ import { flagUnrequestedStatusChange } from "../lib/entity-response.js";
11
13
  import {
12
14
  resolveSecurityConfig, redactCanonicalEntity,
13
15
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
@@ -62,9 +64,10 @@ async function getMedia({ site: siteName, type, id }) {
62
64
  }
63
65
 
64
66
  /**
65
- * Create a media entity. Caller `fields` are spread into attributes; name and
66
- * status are layered on top. Defaults to unpublished (`status: false`) so
67
- * media is never auto-published under non-publishing presets (#139).
67
+ * Create a media entity. Caller `fields` are split into attributes and
68
+ * relationship-shaped references (#171); name and status are layered on top of
69
+ * the attributes. Defaults to unpublished (`status: false`) so media is never
70
+ * auto-published under non-publishing presets (#139).
68
71
  *
69
72
  * @param {object} args - { site?, type, name, status?, fields? }.
70
73
  * @returns {Promise<object>} The created media descriptor.
@@ -73,17 +76,23 @@ async function createMedia({ site: siteName, type, name, status = false, fields
73
76
  const site = getSiteConfig(siteName);
74
77
  const sec = resolveSecurityConfig(site);
75
78
  assertWriteAllowed(sec, "create", "media", type);
76
- const attributes = { name, status, ...fields };
79
+ const { attributes, relationships } = splitReferenceFields(fields);
77
80
  // Layer name/status after fields so they win, matching prior behaviour.
78
81
  attributes.name = name;
79
82
  attributes.status = status;
80
83
  assertPublishAllowed(sec, attributes);
81
84
  const backend = await resolveBackend(site);
82
- return backend.createEntity({ entityType: "media", bundle: type, attributes });
85
+ return backend.createEntity({
86
+ entityType: "media", bundle: type, attributes,
87
+ ...(relationships ? { relationships } : {}),
88
+ });
83
89
  }
84
90
 
85
91
  /**
86
- * Update a media entity (partial — omitted fields are left untouched).
92
+ * Update a media entity (partial — omitted fields are left untouched; `status`
93
+ * is strictly opt-in, #171). Reference-shaped `fields` values are routed to
94
+ * relationships — see splitReferenceFields.
95
+ *
87
96
  * @param {object} args - { site?, type, id, name?, status?, fields? }.
88
97
  * @returns {Promise<object>} The updated media descriptor.
89
98
  */
@@ -91,12 +100,25 @@ async function updateMedia({ site: siteName, type, id, name, status, fields = {}
91
100
  const site = getSiteConfig(siteName);
92
101
  const sec = resolveSecurityConfig(site);
93
102
  assertWriteAllowed(sec, "update", "media", type);
94
- const attributes = { ...fields };
103
+ const { attributes, relationships } = splitReferenceFields(fields);
95
104
  if (name !== undefined) attributes.name = name;
96
105
  if (status !== undefined) attributes.status = status;
97
106
  assertPublishAllowed(sec, attributes);
98
107
  const backend = await resolveBackend(site);
99
- return backend.updateEntity({ entityType: "media", bundle: type, id, attributes });
108
+ // #171: pre-read so an unrequested published-state flip is reported, not silent.
109
+ let existing = null;
110
+ if (status === undefined) {
111
+ try {
112
+ existing = (await backend.getEntity({ entityType: "media", bundle: type, id })) ?? null;
113
+ } catch {
114
+ existing = null; // Unreadable target: server-side gates stay authoritative.
115
+ }
116
+ }
117
+ const result = await backend.updateEntity({
118
+ entityType: "media", bundle: type, id, attributes,
119
+ ...(relationships ? { relationships } : {}),
120
+ });
121
+ return flagUnrequestedStatusChange(result, existing, attributes);
100
122
  }
101
123
 
102
124
  /**
@@ -239,13 +261,13 @@ export const definitions = [
239
261
  type: { type: "string", description: "Media type machine name" },
240
262
  name: { type: "string", description: "Media entity name / label" },
241
263
  status: { type: "boolean", default: false, description: "Published flag. Defaults to false (unpublished). Requires allowPublish when true." },
242
- fields: { type: "object", description: "Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')" },
264
+ fields: { type: "object", description: "Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...'). Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically." },
243
265
  },
244
266
  },
245
267
  },
246
268
  {
247
269
  name: "drupal_update_media",
248
- description: "Update a media entity's name, status, or field values.",
270
+ description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.",
249
271
  inputSchema: {
250
272
  type: "object", required: ["type", "id"],
251
273
  properties: {
@@ -253,8 +275,8 @@ export const definitions = [
253
275
  type: { type: "string" },
254
276
  id: { type: "string" },
255
277
  name: { type: "string" },
256
- status: { type: "boolean" },
257
- fields: { type: "object" },
278
+ status: { type: "boolean", description: "Published flag. Only sent when provided; requires allowPublish when true." },
279
+ fields: { type: "object", description: "Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically." },
258
280
  },
259
281
  },
260
282
  },
@@ -13,8 +13,8 @@ import {
13
13
  resolveSecurityConfig, redactCanonicalEntity,
14
14
  assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
15
15
  } from "../lib/security.js";
16
- import { applySafeDraftDefault } from "../lib/moderation-default.js";
17
- import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
16
+ import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
17
+ import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
18
18
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
19
19
 
20
20
  /** Fallback language for an alias when the node exposes none. */
@@ -318,10 +318,20 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
318
318
  else if (status !== undefined) attributes.status = status;
319
319
  const bodyAttr = buildBodyAttribute(body, summary, format, site);
320
320
  if (bodyAttr) attributes.body = bodyAttr;
321
+ // One pre-read serves the #131 draft default and the #171 unrequested-
322
+ // status-change flag. Skipped when the caller pinned the moderation state.
323
+ let existing = null;
324
+ if (!hasExplicitModerationState(attributes)) {
325
+ try {
326
+ existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
327
+ } catch {
328
+ existing = null; // Unreadable target: server-side gates stay authoritative.
329
+ }
330
+ }
321
331
  // #131: published moderated nodes without an explicit state → draft forward revision.
322
332
  // Runs before the publish gate and on dryRun so previews match the real write.
323
333
  attributes = await applySafeDraftDefault({
324
- backend, entityType: "node", bundle: type, id, attributes,
334
+ backend, entityType: "node", bundle: type, id, attributes, existingEntity: existing,
325
335
  });
326
336
  assertPublishAllowed(sec, attributes);
327
337
  if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes, relationships };
@@ -337,8 +347,11 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
337
347
  // Honest response: re-read persisted state so the returned `url` is the alias
338
348
  // that actually resolves, never the just-sent value.
339
349
  const fresh = await backend.getEntity({ entityType: "node", bundle: type, id }).catch(() => null);
340
- if (fresh && redirectResult) return shapeWriteResponse({ ...fresh, _redirect: redirectResult }, returning);
341
- return shapeWriteResponse(fresh ?? { id }, returning);
350
+ // #171: an unrequested published-state flip in the persisted node is
351
+ // reported via _statusChanged rather than returned as a clean success.
352
+ const flagged = flagUnrequestedStatusChange(fresh, existing, attributes);
353
+ if (flagged && redirectResult) return shapeWriteResponse({ ...flagged, _redirect: redirectResult }, returning);
354
+ return shapeWriteResponse(flagged ?? { id }, returning);
342
355
  }
343
356
 
344
357
  /**