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.
- package/.claude/commands/drupal-create-media.md +1 -1
- package/.claude/commands/drupal-update-media.md +4 -4
- package/CHANGELOG.md +78 -0
- package/README.md +1 -0
- package/bin/drupal-mcp-verify.js +147 -0
- package/config/config.example.json +31 -29
- package/package.json +7 -4
- package/src/lib/canonical.js +48 -0
- package/src/lib/entity-response.js +41 -0
- package/src/lib/security.js +27 -4
- package/src/lib/verify.js +916 -0
- package/src/tools/entities.js +20 -4
- package/src/tools/media.js +34 -12
- package/src/tools/nodes.js +18 -5
package/src/tools/entities.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/tools/media.js
CHANGED
|
@@ -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
|
|
66
|
-
* status are layered on top
|
|
67
|
-
*
|
|
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
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
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
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -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
|
-
|
|
341
|
-
|
|
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
|
/**
|