drupal-mcp-connector 1.8.1 → 2.1.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-bulk-update.md +2 -2
- package/.claude/commands/drupal-create-media.md +1 -1
- package/.claude/commands/drupal-drush-sql-query.md +2 -2
- package/.claude/commands/drupal-entity-update.md +2 -2
- package/.claude/commands/drupal-update-node.md +3 -3
- package/.claude/commands/drupal-upload-file-and-create-media.md +4 -4
- package/.claude/commands/drupal-upload-file.md +1 -1
- package/CHANGELOG.md +94 -0
- package/README.md +31 -0
- package/config/config.example.json +1 -1
- package/package.json +1 -1
- package/scripts/generate-commands.js +4 -0
- package/src/index.js +27 -9
- package/src/lib/drupal-fetch.js +16 -6
- package/src/lib/link-checker.js +3 -1
- package/src/lib/moderation-default.js +106 -0
- package/src/lib/security.js +23 -13
- package/src/lib/validate.js +134 -4
- package/src/tools/bulk.js +13 -3
- package/src/tools/drush.js +67 -10
- package/src/tools/entities.js +12 -4
- package/src/tools/media.js +43 -13
- package/src/tools/moderation.js +7 -2
- package/src/tools/nodes.js +32 -9
- package/src/tools/redirects.js +9 -5
- package/src/tools/taxonomy.js +10 -1
package/src/tools/nodes.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import { getSiteConfig } from "../lib/config.js";
|
|
11
11
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
resolveSecurityConfig, redactCanonicalEntity,
|
|
14
|
+
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
15
|
+
} from "../lib/security.js";
|
|
16
|
+
import { applySafeDraftDefault } from "../lib/moderation-default.js";
|
|
13
17
|
import { shapeWriteResponse, RETURNING_SCHEMA } from "../lib/entity-response.js";
|
|
14
18
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
15
19
|
|
|
@@ -156,6 +160,7 @@ function pageOf({ limit = 20, offset = 0 }) {
|
|
|
156
160
|
async function getNode({ site: siteName, type, id }) {
|
|
157
161
|
const site = getSiteConfig(siteName);
|
|
158
162
|
const sec = resolveSecurityConfig(site);
|
|
163
|
+
assertReadAllowed(sec, "node", type);
|
|
159
164
|
const backend = await resolveBackend(site);
|
|
160
165
|
const entity = await backend.getEntity({ entityType: "node", bundle: type, id });
|
|
161
166
|
return entity ? redactCanonicalEntity(entity, sec, "node") : null;
|
|
@@ -172,6 +177,7 @@ async function getNode({ site: siteName, type, id }) {
|
|
|
172
177
|
async function listNodes({ site: siteName, type, status, filters = [], limit = 20, offset = 0, sort = [{ field: "changed", dir: "desc" }] }) {
|
|
173
178
|
const site = getSiteConfig(siteName);
|
|
174
179
|
const sec = resolveSecurityConfig(site);
|
|
180
|
+
assertReadAllowed(sec, "node", type);
|
|
175
181
|
const backend = await resolveBackend(site);
|
|
176
182
|
const allFilters = [...filters];
|
|
177
183
|
if (status !== undefined) allFilters.push({ field: "status", op: "eq", value: status });
|
|
@@ -195,10 +201,12 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
|
|
|
195
201
|
async function searchContent({ site: siteName, query, type, status, limit = 10 }) {
|
|
196
202
|
const site = getSiteConfig(siteName);
|
|
197
203
|
const sec = resolveSecurityConfig(site);
|
|
204
|
+
const bundle = type || "article";
|
|
205
|
+
assertReadAllowed(sec, "node", bundle);
|
|
198
206
|
const backend = await resolveBackend(site);
|
|
199
207
|
const filters = [{ field: "title", op: "contains", value: query }];
|
|
200
208
|
if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
|
|
201
|
-
const res = await backend.listEntities({ entityType: "node", bundle
|
|
209
|
+
const res = await backend.listEntities({ entityType: "node", bundle, filters, sort: [{ field: "changed", dir: "desc" }], page: { limit } });
|
|
202
210
|
return res.entities.map((e) => redactCanonicalEntity(e, sec, "node"));
|
|
203
211
|
}
|
|
204
212
|
|
|
@@ -225,6 +233,8 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
|
|
|
225
233
|
*/
|
|
226
234
|
async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
227
235
|
const site = getSiteConfig(siteName);
|
|
236
|
+
const sec = resolveSecurityConfig(site);
|
|
237
|
+
assertWriteAllowed(sec, "create", "node", type);
|
|
228
238
|
const attributes = { title, ...fields };
|
|
229
239
|
if (moderationState !== undefined) {
|
|
230
240
|
attributes.moderation_state = moderationState;
|
|
@@ -233,7 +243,7 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
233
243
|
}
|
|
234
244
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
235
245
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
236
|
-
assertPublishAllowed(
|
|
246
|
+
assertPublishAllowed(sec, attributes);
|
|
237
247
|
if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
|
|
238
248
|
const backend = await resolveBackend(site);
|
|
239
249
|
// Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
|
|
@@ -256,6 +266,11 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
256
266
|
* bundles (sends `moderation_state`, omits `status`) or `status` for non-moderated
|
|
257
267
|
* types. `moderationState` takes precedence; both are optional on update.
|
|
258
268
|
*
|
|
269
|
+
* Safe default (#131): when the target is a *published* moderated node and the
|
|
270
|
+
* caller did not pass `moderationState` / `moderation_state`, the write is
|
|
271
|
+
* forced to `moderation_state: draft` so it becomes a forward revision rather
|
|
272
|
+
* than mutating the live default revision. Opt out by passing an explicit state.
|
|
273
|
+
*
|
|
259
274
|
* Alias hardening: a partial update that doesn't touch `path` can still lose the
|
|
260
275
|
* node's URL alias when a module (e.g. Pathauto, in automatic mode) regenerates
|
|
261
276
|
* it on save. To preserve the existing alias, when the caller supplies no `path`
|
|
@@ -269,16 +284,22 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
269
284
|
*/
|
|
270
285
|
async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
|
|
271
286
|
const site = getSiteConfig(siteName);
|
|
272
|
-
const
|
|
287
|
+
const sec = resolveSecurityConfig(site);
|
|
288
|
+
assertWriteAllowed(sec, "update", "node", type);
|
|
289
|
+
const backend = await resolveBackend(site);
|
|
290
|
+
let attributes = { ...fields };
|
|
273
291
|
if (title !== undefined) attributes.title = title;
|
|
274
292
|
if (moderationState !== undefined) attributes.moderation_state = moderationState;
|
|
275
293
|
else if (status !== undefined) attributes.status = status;
|
|
276
294
|
const bodyAttr = buildBodyAttribute(body, summary);
|
|
277
295
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
278
|
-
|
|
296
|
+
// #131: published moderated nodes without an explicit state → draft forward revision.
|
|
297
|
+
// Runs before the publish gate and on dryRun so previews match the real write.
|
|
298
|
+
attributes = await applySafeDraftDefault({
|
|
299
|
+
backend, entityType: "node", bundle: type, id, attributes,
|
|
300
|
+
});
|
|
301
|
+
assertPublishAllowed(sec, attributes);
|
|
279
302
|
if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes, relationships };
|
|
280
|
-
const backend = await resolveBackend(site);
|
|
281
|
-
const sec = resolveSecurityConfig(site);
|
|
282
303
|
// Alias handling (DEV-116): an explicit `path.alias` is set in place by
|
|
283
304
|
// round-tripping the existing alias's pid (no duplicate); a path-less update
|
|
284
305
|
// re-pins the current alias *with its pid* so the save can't revert/duplicate
|
|
@@ -304,6 +325,8 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
|
|
|
304
325
|
*/
|
|
305
326
|
async function deleteNode({ site: siteName, type, id, dryRun = false }) {
|
|
306
327
|
const site = getSiteConfig(siteName);
|
|
328
|
+
const sec = resolveSecurityConfig(site);
|
|
329
|
+
assertDeleteAllowed(sec, "node", type, id);
|
|
307
330
|
if (dryRun) return { dryRun: true, operation: "delete", entityType: "node", bundle: type, id };
|
|
308
331
|
const backend = await resolveBackend(site);
|
|
309
332
|
await backend.deleteEntity({ entityType: "node", bundle: type, id });
|
|
@@ -379,7 +402,7 @@ export const definitions = [
|
|
|
379
402
|
},
|
|
380
403
|
{
|
|
381
404
|
name: "drupal_update_node",
|
|
382
|
-
description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. Entity-reference fields go in `relationships`, not `fields`.",
|
|
405
|
+
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`.",
|
|
383
406
|
inputSchema: {
|
|
384
407
|
type: "object", required: ["type", "id"],
|
|
385
408
|
properties: {
|
|
@@ -390,7 +413,7 @@ export const definitions = [
|
|
|
390
413
|
body: { type: "string" },
|
|
391
414
|
summary: { type: "string" },
|
|
392
415
|
status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
|
|
393
|
-
moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status." },
|
|
416
|
+
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'." },
|
|
394
417
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
|
|
395
418
|
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 }, …] }." },
|
|
396
419
|
dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the update without committing." },
|
package/src/tools/redirects.js
CHANGED
|
@@ -19,11 +19,15 @@
|
|
|
19
19
|
* - `status_code` defaults to 301 and can be set to 302 (or another redirect
|
|
20
20
|
* code) explicitly on create, and changed on an existing redirect via update.
|
|
21
21
|
*
|
|
22
|
-
* Redirect
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* Redirect module ≥ dev-1.x makes redirects publishable: an `enabled` base field
|
|
23
|
+
* (the published key) decides whether the redirect fires. This connector does not
|
|
24
|
+
* send `enabled` — sites govern that flag server-side (mcp_sentinel's publish
|
|
25
|
+
* gate exempted it as routing metadata as of its post-1.9 release; on older
|
|
26
|
+
* sentinel releases a deny-publish profile silently disables agent-created
|
|
27
|
+
* redirects — verify `enabled` after create on such sites). On pre-publishable
|
|
28
|
+
* redirect releases the field is absent and every redirect with a valid source
|
|
29
|
+
* is active. Both tools are governed: writes assert create/update permission for
|
|
30
|
+
* the `redirect` entity type against the per-site security policy.
|
|
27
31
|
*/
|
|
28
32
|
|
|
29
33
|
import { getSiteConfig } from "../lib/config.js";
|
package/src/tools/taxonomy.js
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { getSiteConfig } from "../lib/config.js";
|
|
10
10
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
resolveSecurityConfig, redactCanonicalEntity,
|
|
13
|
+
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed,
|
|
14
|
+
} from "../lib/security.js";
|
|
12
15
|
|
|
13
16
|
// ---------------------------------------------------------------------------
|
|
14
17
|
// Implementations
|
|
@@ -22,6 +25,7 @@ import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js
|
|
|
22
25
|
*/
|
|
23
26
|
async function listVocabularies({ site: siteName }) {
|
|
24
27
|
const site = getSiteConfig(siteName);
|
|
28
|
+
assertReadAllowed(resolveSecurityConfig(site), "taxonomy_term");
|
|
25
29
|
const backend = await resolveBackend(site);
|
|
26
30
|
return backend.listBundles("taxonomy_term");
|
|
27
31
|
}
|
|
@@ -35,6 +39,7 @@ async function listVocabularies({ site: siteName }) {
|
|
|
35
39
|
async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset = 0 }) {
|
|
36
40
|
const site = getSiteConfig(siteName);
|
|
37
41
|
const sec = resolveSecurityConfig(site);
|
|
42
|
+
assertReadAllowed(sec, "taxonomy_term", vocabulary);
|
|
38
43
|
const backend = await resolveBackend(site);
|
|
39
44
|
const res = await backend.listEntities({
|
|
40
45
|
entityType: "taxonomy_term", bundle: vocabulary,
|
|
@@ -56,6 +61,7 @@ async function getTaxonomyTerms({ site: siteName, vocabulary, limit = 50, offset
|
|
|
56
61
|
async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
|
|
57
62
|
const site = getSiteConfig(siteName);
|
|
58
63
|
const sec = resolveSecurityConfig(site);
|
|
64
|
+
assertReadAllowed(sec, "taxonomy_term", vocabulary);
|
|
59
65
|
const backend = await resolveBackend(site);
|
|
60
66
|
const entity = await backend.getEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
|
|
61
67
|
return entity ? redactCanonicalEntity(entity, sec, "taxonomy_term") : null;
|
|
@@ -70,6 +76,7 @@ async function getTaxonomyTerm({ site: siteName, vocabulary, id }) {
|
|
|
70
76
|
*/
|
|
71
77
|
async function createTaxonomyTerm({ site: siteName, vocabulary, name, description, weight = 0, parentId }) {
|
|
72
78
|
const site = getSiteConfig(siteName);
|
|
79
|
+
assertWriteAllowed(resolveSecurityConfig(site), "create", "taxonomy_term", vocabulary);
|
|
73
80
|
const backend = await resolveBackend(site);
|
|
74
81
|
const attributes = { name, weight };
|
|
75
82
|
if (description !== undefined) attributes.description = { value: description, format: "plain_text" };
|
|
@@ -88,6 +95,7 @@ async function createTaxonomyTerm({ site: siteName, vocabulary, name, descriptio
|
|
|
88
95
|
*/
|
|
89
96
|
async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, description, weight }) {
|
|
90
97
|
const site = getSiteConfig(siteName);
|
|
98
|
+
assertWriteAllowed(resolveSecurityConfig(site), "update", "taxonomy_term", vocabulary);
|
|
91
99
|
const backend = await resolveBackend(site);
|
|
92
100
|
const attributes = {};
|
|
93
101
|
if (name !== undefined) attributes.name = name;
|
|
@@ -105,6 +113,7 @@ async function updateTaxonomyTerm({ site: siteName, vocabulary, id, name, descri
|
|
|
105
113
|
*/
|
|
106
114
|
async function deleteTaxonomyTerm({ site: siteName, vocabulary, id }) {
|
|
107
115
|
const site = getSiteConfig(siteName);
|
|
116
|
+
assertDeleteAllowed(resolveSecurityConfig(site), "taxonomy_term", vocabulary, id);
|
|
108
117
|
const backend = await resolveBackend(site);
|
|
109
118
|
await backend.deleteEntity({ entityType: "taxonomy_term", bundle: vocabulary, id });
|
|
110
119
|
return { success: true, deletedId: id };
|