drupal-mcp-connector 2.4.1 → 2.5.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.
@@ -17,6 +17,6 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
17
17
  **Optional:**
18
18
  - `site` (string): omit for the default site
19
19
  - `status` (boolean (true/false)): Published flag. Defaults to false (unpublished). Requires allowPublish when true.
20
- - `fields` (object (pass as JSON)): Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')
20
+ - `fields` (object (pass as JSON)): 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.
21
21
 
22
22
  If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Update a media entity's name, status, or field values."
2
+ description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched."
3
3
  argument-hint: "<type> <id> [site] [name] [status] [fields]"
4
4
  allowed-tools: mcp__drupal__drupal_update_media
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_update_media` MCP tool.
8
8
 
9
- Update a media entity's name, status, or field values.
9
+ Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -17,7 +17,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
17
17
  **Optional:**
18
18
  - `site` (string): omit for the default site
19
19
  - `name` (string)
20
- - `status` (boolean (true/false))
21
- - `fields` (object (pass as JSON))
20
+ - `status` (boolean (true/false)): Published flag. Only sent when provided; requires allowPublish when true.
21
+ - `fields` (object (pass as JSON)): Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically.
22
22
 
23
23
  If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.5.0] - 2026-08-14
11
+
12
+ ### Fixed
13
+ - **An unrequested published-state change is no longer silent (#171).**
14
+ `status` stays strictly opt-in on updates — the connector never adds it to a
15
+ PATCH — but a server-side gate can still flip it (an unmoderated-entity
16
+ publish backstop, or a write landing as an unpublished forward revision).
17
+ `drupal_entity_update`, `drupal_update_node`, and `drupal_update_media` now
18
+ compare the written state against a pre-write read and, when the caller sent
19
+ neither `status` nor an explicit moderation state, report a flip via a
20
+ `_statusChanged` marker (`from`/`to` plus a verification note) instead of
21
+ returning a clean success. The marker survives `returning: "minimal"`.
22
+ Regression tests pin that relationships-only and field-only updates send
23
+ neither `status` nor `moderation_state` across the entity, media, node, and
24
+ bulk update tools.
25
+ - **Media tools route reference-shaped `fields` to relationships (#171).**
26
+ `drupal_update_media` and `drupal_create_media` forwarded entity-reference
27
+ values under `fields` as JSON:API attributes, which Drupal rejects with a 422
28
+ ("relationship fields were provided as attributes"). Values in linkage shape
29
+ (`{ data: { type, id } }`, an array of those, or `{ data: null }` to clear)
30
+ are now sent as relationships, matching what `drupal_entity_update` accepts;
31
+ composite attribute values (`{ value, format }` and friends) are untouched.
32
+ The linkage-shape helpers live in `src/lib/canonical.js` for reuse.
33
+
10
34
  ## [2.4.1] - 2026-08-14
11
35
 
12
36
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -66,3 +66,51 @@ export function normalizeRelationship(ref) {
66
66
  const [entityType = null, bundle = null] = (ref.type || "").split("--");
67
67
  return { id: ref.id, entityType, bundle };
68
68
  }
69
+
70
+ /**
71
+ * Whether a field value is a JSON:API relationship linkage (`{ data: ... }`
72
+ * where data is null, one `{ type, id }` reference, or an array of them —
73
+ * an empty array clears a multi-value reference).
74
+ *
75
+ * Composite attribute values (e.g. `{ value, format }` text fields) have no
76
+ * `data` key and are never matched, so ordinary attributes pass through.
77
+ *
78
+ * @param {*} value A caller-supplied field value.
79
+ * @returns {boolean} True when the value is relationship-shaped.
80
+ */
81
+ export function isRelationshipLinkage(value) {
82
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
83
+ if (!Object.prototype.hasOwnProperty.call(value, "data")) return false;
84
+ const isRef = (d) => Boolean(d) && typeof d === "object" && !Array.isArray(d)
85
+ && typeof d.type === "string" && typeof d.id === "string";
86
+ const { data } = value;
87
+ if (data === null) return true;
88
+ if (Array.isArray(data)) return data.every(isRef);
89
+ return isRef(data);
90
+ }
91
+
92
+ /**
93
+ * Split a caller field map into JSON:API attributes and relationships (#171).
94
+ *
95
+ * Entity-reference values passed under a `fields` map used to be forwarded as
96
+ * attributes, which Drupal rejects with a 422 ("relationship fields were
97
+ * provided as attributes"). Relationship-shaped values are routed to the
98
+ * `relationships` document member instead, so field-map tools accept the same
99
+ * reference shape as `drupal_entity_update`.
100
+ *
101
+ * @param {object} fields Caller-supplied field map.
102
+ * @returns {{attributes: object, relationships: ?object}} Split maps;
103
+ * `relationships` is null when no value was relationship-shaped.
104
+ */
105
+ export function splitReferenceFields(fields) {
106
+ const attrEntries = [];
107
+ const relEntries = [];
108
+ for (const entry of Object.entries(fields)) {
109
+ const [, value] = entry;
110
+ (isRelationshipLinkage(value) ? relEntries : attrEntries).push(entry);
111
+ }
112
+ return {
113
+ attributes: Object.fromEntries(attrEntries),
114
+ relationships: relEntries.length ? Object.fromEntries(relEntries) : null,
115
+ };
116
+ }
@@ -34,6 +34,47 @@ export function shapeWriteResponse(entity, returning = "full") {
34
34
  return out;
35
35
  }
36
36
 
37
+ /**
38
+ * Flag a published-state change the caller never requested (#171).
39
+ *
40
+ * `status` is strictly opt-in on updates: the connector never adds it to a
41
+ * PATCH. A server-side gate can still flip it (e.g. a governance backstop
42
+ * unpublishing an unmoderated entity, or a write landing as an unpublished
43
+ * forward revision), and a silent success that also changed live state is the
44
+ * failure class that surfaces only when content goes missing. When a
45
+ * pre-write read is available and the sent attributes carry neither `status`
46
+ * nor `moderation_state` (an explicit moderation transition legitimately
47
+ * changes the published state, as does the #131 injected draft default), a
48
+ * different `status` in the write result is surfaced as `_statusChanged` — an
49
+ * `_`-prefixed key, so it survives `returning: "minimal"`.
50
+ *
51
+ * Best-effort by design: it requires a readable pre-write entity, and the
52
+ * server-side gate stays authoritative either way.
53
+ *
54
+ * @param {?object} result The entity returned by (or re-read after) the write.
55
+ * @param {?object} existing The pre-write entity, when it could be read.
56
+ * @param {object} sentAttributes The attribute map that was sent.
57
+ * @returns {?object} The result, with `_statusChanged` attached when it applies.
58
+ */
59
+ export function flagUnrequestedStatusChange(result, existing, sentAttributes) {
60
+ if (!result || !existing) return result;
61
+ const sent = (key) => Object.prototype.hasOwnProperty.call(sentAttributes, key);
62
+ if (sent("status") || sent("moderation_state")) return result;
63
+ if (typeof existing.status !== "boolean" || typeof result.status !== "boolean") return result;
64
+ if (existing.status === result.status) return result;
65
+ return {
66
+ ...result,
67
+ _statusChanged: {
68
+ from: existing.status,
69
+ to: result.status,
70
+ note: "The returned published status differs from the pre-write state although the request did not " +
71
+ "include `status`. A server-side gate intervened — the write may have landed as an unpublished " +
72
+ "forward revision (live revision unchanged) or the entity may have been unpublished. Verify which " +
73
+ "revision is live before relying on this content's visibility.",
74
+ },
75
+ };
76
+ }
77
+
37
78
  /** JSON Schema fragment for the shared `returning` parameter. */
38
79
  export const RETURNING_SCHEMA = {
39
80
  type: "string",
@@ -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
  /**